commit c8b95245f6295649b2bcebf6b46c1de92fe19d2b Author: gajah123-cmd <158025334+OPPabcd@users.noreply.github.com> Date: Mon Aug 3 09:12:27 2026 +0700 Initial commit diff --git a/.agents/workflows/apk.md b/.agents/workflows/apk.md new file mode 100644 index 0000000..fdc1622 --- /dev/null +++ b/.agents/workflows/apk.md @@ -0,0 +1,4 @@ +--- +description: +--- + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /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-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..51c0bb5 --- /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: "db50e20168db8fee486b9abf32fc912de3bc5b6a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: android + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: ios + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: linux + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: macos + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: web + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: windows + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + + # 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/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..74baffc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["denoland.vscode-deno"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..85b52e2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "deno.enablePaths": [ + "supabase/functions" + ], + "deno.lint": true, + "deno.unstable": [ + "bare-node-builtins", + "byonm", + "sloppy-imports", + "unsafe-proto", + "webgpu", + "broadcast-channel", + "worker-options", + "cron", + "kv", + "ffi", + "fs", + "http", + "net" + ], + "java.configuration.updateBuildConfiguration": "interactive" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..56b826b --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# apk_full + +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: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +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..62a7a9c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // END: FlutterFire Configuration + 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.apk_full" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.apk_full" + // 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/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..29982e1 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "1078157366732", + "project_id": "fmc-firebasecloudmessaging", + "storage_bucket": "fmc-firebasecloudmessaging.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1078157366732:android:cff3b9bc3759762b76f969", + "android_client_info": { + "package_name": "com.example.apk_full" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDDAyv2LzeOXJfyDEysM1XvrW8AuC4d2U0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file 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..f02ed77 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/apk_full/MainActivity.kt b/android/app/src/main/kotlin/com/example/apk_full/MainActivity.kt new file mode 100644 index 0000000..572bd0d --- /dev/null +++ b/android/app/src/main/kotlin/com/example/apk_full/MainActivity.kt @@ -0,0 +1,6 @@ +package com.example.apk_full + +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..ef77be8 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..4880cfa 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..ac94c83 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..898acd5 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..e2634dd 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..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /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.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..117581b --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,29 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.4.4") apply false + // END: FlutterFire Configuration + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..57e0b5a --- /dev/null +++ b/firebase.json @@ -0,0 +1 @@ +{"flutter":{"platforms":{"android":{"default":{"projectId":"fmc-firebasecloudmessaging","appId":"1:1078157366732:android:cff3b9bc3759762b76f969","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"fmc-firebasecloudmessaging","configurations":{"android":"1:1078157366732:android:cff3b9bc3759762b76f969"}}}}}} \ No newline at end of file diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml new file mode 100644 index 0000000..44776a7 --- /dev/null +++ b/flutter_launcher_icons.yaml @@ -0,0 +1,10 @@ +# flutter pub run flutter_launcher_icons +flutter_launcher_icons: + image_path: "lib/assets/icon_app/icon_app.png" + + android: "ic_launcher" + min_sdk_android: 21 + + ios: true + remove_alpha_ios: true + 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..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.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..6748f07 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*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 */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.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 = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.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 */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.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 */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift 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 = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.apkFull; + 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.apkFull.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.apkFull.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.apkFull.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 = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.apkFull; + 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.apkFull; + 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..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} 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..d0d98aa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"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":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"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":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@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"}} \ No newline at end of file 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..f20b4f9 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..0153d18 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..d7c9fe1 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..682316f 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..eb7b028 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..dc1a3c6 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..fdf7215 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..d7c9fe1 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..06e3c20 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..d83f038 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-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..bd0404e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..03cb30f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..111d2ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..ff029d3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.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..d83f038 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..f754852 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-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..2277be1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..25ca26f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.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..1edc519 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..48559d6 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..6f175f3 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..4822e78 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Apk Full + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + apk_full + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + 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/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} 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/.env b/lib/.env new file mode 100644 index 0000000..285aefa --- /dev/null +++ b/lib/.env @@ -0,0 +1,4 @@ +SUPABASE_URL=https://iiucrouixgueeyhyqujs.supabase.co +SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlpdWNyb3VpeGd1ZWV5aHlxdWpzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg3MjAwMTYsImV4cCI6MjA4NDI5NjAxNn0.JoquYZ2qxsYk3RE4Ih_KJxoHPjYp53SPgbF71CX8nKc +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlpdWNyb3VpeGd1ZWV5aHlxdWpzIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2ODcyMDAxNiwiZXhwIjoyMDg0Mjk2MDE2fQ.QT2SL4hXI6E8U0dPXbRvcsSmby_fAcl8l2GgEVozAVM +ENTRIG_API_KEY=sk-proj-e44a3eff-88b577cdef1ef9cb1043ef507bb3423bfc9d3525638ff2415945791c73143d8a diff --git a/lib/assets/fonts/DS-DIGI.TTF b/lib/assets/fonts/DS-DIGI.TTF new file mode 100644 index 0000000..0925877 Binary files /dev/null and b/lib/assets/fonts/DS-DIGI.TTF differ diff --git a/lib/assets/fonts/Inter_18pt-Bold.ttf b/lib/assets/fonts/Inter_18pt-Bold.ttf new file mode 100644 index 0000000..57704d1 Binary files /dev/null and b/lib/assets/fonts/Inter_18pt-Bold.ttf differ diff --git a/lib/assets/fonts/Inter_18pt-Medium.ttf b/lib/assets/fonts/Inter_18pt-Medium.ttf new file mode 100644 index 0000000..71d9017 Binary files /dev/null and b/lib/assets/fonts/Inter_18pt-Medium.ttf differ diff --git a/lib/assets/fonts/Inter_18pt-Regular.ttf b/lib/assets/fonts/Inter_18pt-Regular.ttf new file mode 100644 index 0000000..ce097c8 Binary files /dev/null and b/lib/assets/fonts/Inter_18pt-Regular.ttf differ diff --git a/lib/assets/fonts/Inter_18pt-SemiBold.ttf b/lib/assets/fonts/Inter_18pt-SemiBold.ttf new file mode 100644 index 0000000..053185e Binary files /dev/null and b/lib/assets/fonts/Inter_18pt-SemiBold.ttf differ diff --git a/lib/assets/fonts/Inter_24pt-Bold.ttf b/lib/assets/fonts/Inter_24pt-Bold.ttf new file mode 100644 index 0000000..e974d96 Binary files /dev/null and b/lib/assets/fonts/Inter_24pt-Bold.ttf differ diff --git a/lib/assets/fonts/Inter_24pt-Medium.ttf b/lib/assets/fonts/Inter_24pt-Medium.ttf new file mode 100644 index 0000000..5c88739 Binary files /dev/null and b/lib/assets/fonts/Inter_24pt-Medium.ttf differ diff --git a/lib/assets/fonts/Inter_24pt-Regular.ttf b/lib/assets/fonts/Inter_24pt-Regular.ttf new file mode 100644 index 0000000..6b088a7 Binary files /dev/null and b/lib/assets/fonts/Inter_24pt-Regular.ttf differ diff --git a/lib/assets/fonts/Inter_24pt-SemiBold.ttf b/lib/assets/fonts/Inter_24pt-SemiBold.ttf new file mode 100644 index 0000000..ceb8576 Binary files /dev/null and b/lib/assets/fonts/Inter_24pt-SemiBold.ttf differ diff --git a/lib/assets/fonts/Inter_28pt-Bold.ttf b/lib/assets/fonts/Inter_28pt-Bold.ttf new file mode 100644 index 0000000..14db994 Binary files /dev/null and b/lib/assets/fonts/Inter_28pt-Bold.ttf differ diff --git a/lib/assets/fonts/Inter_28pt-Medium.ttf b/lib/assets/fonts/Inter_28pt-Medium.ttf new file mode 100644 index 0000000..00120fe Binary files /dev/null and b/lib/assets/fonts/Inter_28pt-Medium.ttf differ diff --git a/lib/assets/fonts/Inter_28pt-Regular.ttf b/lib/assets/fonts/Inter_28pt-Regular.ttf new file mode 100644 index 0000000..855b6f4 Binary files /dev/null and b/lib/assets/fonts/Inter_28pt-Regular.ttf differ diff --git a/lib/assets/fonts/Inter_28pt-SemiBold.ttf b/lib/assets/fonts/Inter_28pt-SemiBold.ttf new file mode 100644 index 0000000..8b84efc Binary files /dev/null and b/lib/assets/fonts/Inter_28pt-SemiBold.ttf differ diff --git a/lib/assets/icon_app/icon_app.png b/lib/assets/icon_app/icon_app.png new file mode 100644 index 0000000..9214385 Binary files /dev/null and b/lib/assets/icon_app/icon_app.png differ diff --git a/lib/assets/icons/Button.png b/lib/assets/icons/Button.png new file mode 100644 index 0000000..c375a18 Binary files /dev/null and b/lib/assets/icons/Button.png differ diff --git a/lib/assets/icons/a_add-p.png b/lib/assets/icons/a_add-p.png new file mode 100644 index 0000000..9feb575 Binary files /dev/null and b/lib/assets/icons/a_add-p.png differ diff --git a/lib/assets/icons/a_bell.png b/lib/assets/icons/a_bell.png new file mode 100644 index 0000000..cffc12c Binary files /dev/null and b/lib/assets/icons/a_bell.png differ diff --git a/lib/assets/icons/a_building.png b/lib/assets/icons/a_building.png new file mode 100644 index 0000000..feeaf67 Binary files /dev/null and b/lib/assets/icons/a_building.png differ diff --git a/lib/assets/icons/a_hat.png b/lib/assets/icons/a_hat.png new file mode 100644 index 0000000..7ed49b6 Binary files /dev/null and b/lib/assets/icons/a_hat.png differ diff --git a/lib/assets/icons/a_mini-date.png b/lib/assets/icons/a_mini-date.png new file mode 100644 index 0000000..37d1eee Binary files /dev/null and b/lib/assets/icons/a_mini-date.png differ diff --git a/lib/assets/icons/a_mini-person.png b/lib/assets/icons/a_mini-person.png new file mode 100644 index 0000000..d7ef2b6 Binary files /dev/null and b/lib/assets/icons/a_mini-person.png differ diff --git a/lib/assets/icons/a_note.png b/lib/assets/icons/a_note.png new file mode 100644 index 0000000..f8ba3fc Binary files /dev/null and b/lib/assets/icons/a_note.png differ diff --git a/lib/assets/icons/a_people.png b/lib/assets/icons/a_people.png new file mode 100644 index 0000000..38cb111 Binary files /dev/null and b/lib/assets/icons/a_people.png differ diff --git a/lib/assets/icons/b_people.png b/lib/assets/icons/b_people.png new file mode 100644 index 0000000..ba8e05d Binary files /dev/null and b/lib/assets/icons/b_people.png differ diff --git a/lib/assets/icons/h_hat.png b/lib/assets/icons/h_hat.png new file mode 100644 index 0000000..10b46b3 Binary files /dev/null and b/lib/assets/icons/h_hat.png differ diff --git a/lib/assets/icons/menu.png b/lib/assets/icons/menu.png new file mode 100644 index 0000000..2da284b Binary files /dev/null and b/lib/assets/icons/menu.png differ diff --git a/lib/assets/icons/w_add-p.png b/lib/assets/icons/w_add-p.png new file mode 100644 index 0000000..de04cdf Binary files /dev/null and b/lib/assets/icons/w_add-p.png differ diff --git a/lib/assets/icons/w_bell.png b/lib/assets/icons/w_bell.png new file mode 100644 index 0000000..f61bd70 Binary files /dev/null and b/lib/assets/icons/w_bell.png differ diff --git a/lib/assets/icons/w_building.png b/lib/assets/icons/w_building.png new file mode 100644 index 0000000..2313228 Binary files /dev/null and b/lib/assets/icons/w_building.png differ diff --git a/lib/assets/icons/w_hat.png b/lib/assets/icons/w_hat.png new file mode 100644 index 0000000..807aa34 Binary files /dev/null and b/lib/assets/icons/w_hat.png differ diff --git a/lib/assets/icons/w_note.png b/lib/assets/icons/w_note.png new file mode 100644 index 0000000..b44bed4 Binary files /dev/null and b/lib/assets/icons/w_note.png differ diff --git a/lib/assets/icons/w_people.png b/lib/assets/icons/w_people.png new file mode 100644 index 0000000..6e9e85e Binary files /dev/null and b/lib/assets/icons/w_people.png differ diff --git a/lib/closed_app/closed.dart b/lib/closed_app/closed.dart new file mode 100644 index 0000000..72f0543 --- /dev/null +++ b/lib/closed_app/closed.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +DateTime? _currentBackPressTime; + +Future handleDoubleTapToExit(BuildContext context) async { + final now = DateTime.now(); + + if (_currentBackPressTime == null || + now.difference(_currentBackPressTime!) > const Duration(seconds: 2)) { + // Jika belum pernah ditekan atau sudah lewat 2 detik + _currentBackPressTime = now; + + // Tampilkan warning + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Tekan tombol kembali sekali lagi untuk keluar', + style: TextStyle(color: Colors.white, fontFamily: 'Inter'), + textAlign: TextAlign.center, + ), + backgroundColor: const Color(0xFF334155), // Slate 700 + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 20, left: 40, right: 40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + duration: const Duration(seconds: 2), + ), + ); + return false; + } + + // Jika ditekan dua kali dalam rentang 2 detik, izinkan keluar + return true; +} diff --git a/lib/dashboard/a_admin/bottom_bar/bottom_nav.dart b/lib/dashboard/a_admin/bottom_bar/bottom_nav.dart new file mode 100644 index 0000000..296d288 --- /dev/null +++ b/lib/dashboard/a_admin/bottom_bar/bottom_nav.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/function/f_notif/notif_reddot.dart'; + +class BottomNav extends StatelessWidget { + final int selectedIndex; + final Function(int) onTap; + + const BottomNav({ + super.key, + required this.selectedIndex, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 50, // Mengatur tinggi bar + child: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + selectedFontSize: 0, + unselectedFontSize: 0, + type: BottomNavigationBarType.fixed, + currentIndex: selectedIndex, + onTap: onTap, + + items: [ + BottomNavigationBarItem( + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selectedIndex == 0 + ? Colors.blue + : Color(0xFFF1F5F9), + ), + child: Image.asset( + selectedIndex == 0 + ? 'lib/assets/icons/w_building.png' + : 'lib/assets/icons/a_building.png', + width: 24, + height: 24, + ), + ), + if (selectedIndex == 0) SizedBox(height: 2), + if (selectedIndex == 0) + Text( + 'Daftar Kelas', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.blue, + ), + ), + ], + ), + label: '', + ), + + BottomNavigationBarItem( + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: NotifRedDot.grupSekolahHasUnread, + builder: (context, unreadGS, child) { + return ValueListenableBuilder( + valueListenable: NotifRedDot.chatPrivateHasUnread, + builder: (context, unreadPrivate, child) { + final bool hasAnyUnread = unreadGS || unreadPrivate; + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selectedIndex == 1 + ? Colors.blue + : const Color(0xFFF1F5F9), + ), + child: Image.asset( + selectedIndex == 1 + ? 'lib/assets/icons/w_bell.png' + : 'lib/assets/icons/a_bell.png', + width: 24, + height: 24, + ), + ), + if (hasAnyUnread) + Positioned( + top: -2, + right: -2, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + ], + ); + }, + ); + }, + ), + if (selectedIndex == 1) SizedBox(height: 2), + if (selectedIndex == 1) + Text('Pengumuman', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.blue, + )), + ], + ), + label: '', + ), + + BottomNavigationBarItem( + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selectedIndex == 2 + ? Colors.blue + : Color(0xFFF1F5F9), + ), + child: Image.asset( + selectedIndex == 2 + ? 'lib/assets/icons/w_add-p.png' + : 'lib/assets/icons/a_add-p.png', + width: 24, + height: 24, + ), + ), + if (selectedIndex == 2) SizedBox(height: 2), + if (selectedIndex == 2) + Text('Tambah', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.blue, + )), + ], + ), + label: '', + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/bottom_bar/main_screen.dart b/lib/dashboard/a_admin/bottom_bar/main_screen.dart new file mode 100644 index 0000000..701458c --- /dev/null +++ b/lib/dashboard/a_admin/bottom_bar/main_screen.dart @@ -0,0 +1,62 @@ +import 'package:apk/dashboard/a_admin/bottom_bar/bottom_nav.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:apk/closed_app/closed.dart'; +import '../dashboard/class.dart'; +import '../dashboard/announcement.dart'; +import '../dashboard/add_person.dart'; +import 'package:apk/dashboard/a_admin/function/f_notif/notif_reddot.dart'; + +class MainScreen extends StatefulWidget { + const MainScreen({super.key}); + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + int selectedIndex = 0; + + @override + void initState() { + super.initState(); + NotificationFCM.init(); + } + + final List pages = [ + const DaftarKelas(), + const Pengumuman(), + const TambahPengguna(), + ]; + + void onItemTapped(int index) { + setState(() { + selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvoked: (bool didPop) async { + if (didPop) return; + final bool shouldPop = await handleDoubleTapToExit(context); + if (shouldPop) { + SystemNavigator.pop(); + } + }, + child: Scaffold( + body: IndexedStack( + index: selectedIndex, + children: pages, + ), + + bottomNavigationBar: BottomNav( + selectedIndex: selectedIndex, + onTap: onItemTapped, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/dashboard/add_person.dart b/lib/dashboard/a_admin/dashboard/add_person.dart new file mode 100644 index 0000000..f59522b --- /dev/null +++ b/lib/dashboard/a_admin/dashboard/add_person.dart @@ -0,0 +1,388 @@ +// import 'package:apk/funtion/daftar_murid.dart'; +import 'package:apk/dashboard/a_admin/function/f_murid/form_murid.dart'; +import 'package:flutter/material.dart'; +//import 'package:apk/dashboard/a_admin/bottom_bar/bottom_nav.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/a_admin/function/f_murid/get_murid.dart'; +import 'package:apk/dashboard/a_admin/function/f_guru/form_guru.dart'; +import 'package:apk/dashboard/a_admin/function/f_guru/get_guru.dart'; +import 'package:apk/dashboard/a_admin/function/f_kelas/form_kelas..dart'; +import 'package:apk/dashboard/a_admin/function/user_admin/profil.dart'; +import 'package:apk/dashboard/a_admin/function/f_alat/config_page.dart'; +import 'package:apk/dashboard/a_admin/function/f_alat/config_user.dart'; +import 'package:apk/error_handler/connection.dart'; + +class TambahPengguna extends StatefulWidget { + const TambahPengguna({super.key}); + + @override + State createState() => _TambahPenggunaState(); +} + +class _TambahPenggunaState extends State { + bool _isLoading = false; + + Future _loadData() async { + if (!await ConnectionHandler.checkConnection()) { + if (mounted) { + setState(() => _isLoading = false); + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () { + setState(() => _isLoading = true); + _loadData(); + }, + ); + } + return; + } + + // Karena menggunakan StreamBuilder, cukup trigger re-render dengan delay simulasi + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + //bottomNavigationBar: const BottomNav(selectedIndex: 2), + appBar: AppBar( + titleSpacing: 20, + title: Column( + //crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Tambah Pengguna", + style: TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + backgroundColor: Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: false, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + onPressed: () { + setState(() { + _isLoading = true; + }); + _loadData(); + }, + ), + ), + ], + ), + + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + //const SizedBox(height: 20), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: ListView( + padding: EdgeInsets.zero, + children: [ + CustomCard( + width: 350, + height: 95, + title: "Tambahkan Akun Murid", + gradient: const LinearGradient( + colors: [ + Color.fromARGB(255, 255, 255, 255), + Colors.lightBlueAccent, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderColor: Colors.transparent, + boxShadow: [ + BoxShadow( + // ignore: deprecated_member_use + color: const Color.fromARGB(255, 3, 3, 3).withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + titleStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + customIcon: Image.asset('lib/assets/icons/b_people.png', + width: 35, + height: 35, + ), + iconDecoration: BoxDecoration( + color: Colors.lightBlueAccent[100], + borderRadius: BorderRadius.circular(12), + ), + iconPosition: IconPosition.left, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FormMuridPage()), + ); + }, + ), + CustomCard( + width: 350, + height: 95, + title: "Tambahkan Akun Guru", + gradient: const LinearGradient( + colors: [ + Color.fromARGB(255, 255, 255, 255), + Colors.greenAccent, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderColor: Colors.transparent, + boxShadow: [ + BoxShadow( + // ignore: deprecated_member_use + color: const Color.fromARGB(255, 3, 3, 3).withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + titleStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + customIcon: Image.asset('lib/assets/icons/h_hat.png', + width: 35, + height: 35, + ), + iconDecoration: BoxDecoration( + color: Colors.greenAccent[100], + borderRadius: BorderRadius.circular(12), + ), + iconPosition: IconPosition.left, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FormGuruPage()), + ); + }, + ), + CustomCard( + width: 350, + height: 95, + title: "Tambahkan Kelas", + gradient: const LinearGradient( + colors: [ + Color.fromARGB(255, 255, 255, 255), + Colors.orangeAccent, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderColor: Colors.transparent, + boxShadow: [ + BoxShadow( + // ignore: deprecated_member_use + color: const Color.fromARGB(255, 3, 3, 3).withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + titleStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + customIcon: Image.asset('lib/assets/icons/menu.png', + width: 35, + height: 35, + ), + iconDecoration: BoxDecoration( + color: Colors.amber[100], + borderRadius: BorderRadius.circular(12), + ), + iconPosition: IconPosition.left, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FormKelasPage()), + ); + }, + ), + + const SizedBox(height: 30), + + const Divider( + thickness: 0.8, + color: Colors.black26, + indent: 10, + endIndent: 10, + ), + + const SizedBox(height: 15), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + CustomCard( + width: 180, + height: 60, + margin: const EdgeInsets.only(bottom: 16), + title: "Daftar Murid", + backgroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 7), + borderColor: Colors.lightBlue, + titleStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color.fromARGB(255, 56, 59, 238), + ), + customIcon: Image.asset('lib/assets/icons/a_note.png', + width: 40, + height: 40, + ), + iconPosition: IconPosition.left, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MuridPage()), + ); + }, + ), + CustomCard( + width: 180, + height: 60, + margin: const EdgeInsets.only(bottom: 16), + title: "Daftar Guru", + backgroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 7), + borderColor: Colors.lightBlue, + titleStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color.fromARGB(255, 56, 59, 238), + ), + customIcon: Image.asset('lib/assets/icons/a_note.png', + width: 40, + height: 40, + ), + iconPosition: IconPosition.left, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GuruPage()), + ); + }, + ), + CustomCard( + width: 180, + height: 60, + margin: EdgeInsets.zero, + title: "Profil Admin", + backgroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 7), + borderColor: Colors.lightBlue, + titleStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color.fromARGB(255, 56, 59, 238), + ), + customIcon: const Icon(Icons.manage_accounts, color: Color(0xFF2563EB), size: 36), + iconPosition: IconPosition.left, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ProfilScreen()), + ); + }, + ), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + children: [ + CustomCard( + width: double.infinity, + height: 98, + margin: const EdgeInsets.only(bottom: 16), + title: "Daftar Alat\nTerhubung", + backgroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 7), + borderColor: Colors.lightBlue, + titleStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color.fromARGB(255, 56, 59, 238), + ), + showIcon: false, + titleAlign: TextAlign.left, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ConfigPage()), + ); + }, + ), + CustomCard( + width: double.infinity, + height: 98, + margin: EdgeInsets.zero, + title: "Pendaftaran\nRFID", + backgroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 7), + borderColor: Colors.lightBlue, + titleStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color.fromARGB(255, 56, 59, 238), + ), + showIcon: false, + titleAlign: TextAlign.left, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ConfigUser()), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/dashboard/announcement.dart b/lib/dashboard/a_admin/dashboard/announcement.dart new file mode 100644 index 0000000..a61a160 --- /dev/null +++ b/lib/dashboard/a_admin/dashboard/announcement.dart @@ -0,0 +1,469 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/a_admin/function/f_pesan/grup_sekolah.dart'; +import 'package:apk/dashboard/a_admin/database/db_guru.dart'; +import 'package:apk/dashboard/a_admin/database/db_siswa.dart'; +import 'package:apk/dashboard/a_admin/function/f_pesan/chat_private.dart'; +import 'package:apk/error_handler/connection.dart'; +import 'package:apk/dashboard/a_admin/database/db_grup_sekolah.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/a_admin/function/f_notif/preview_pesan/grup_sekolah_p.dart'; +import 'package:apk/dashboard/a_admin/function/f_notif/notif_reddot.dart'; + +class Pengumuman extends StatefulWidget { + const Pengumuman({super.key}); + + @override + State createState() => _PengumumanState(); +} + +class _PengumumanState extends State { + final MuridService _muridService = MuridService(); + bool _isLoading = true; + List> _listGuru = []; + List> _listMurid = []; + DateTime? _lastVisitedGrupSekolah; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + if (!await ConnectionHandler.checkConnection()) { + if (mounted) { + setState(() => _isLoading = false); + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () { + setState(() => _isLoading = true); + _loadData(); + }, + ); + } + return; + } + + try { + final prefs = await SharedPreferences.getInstance(); + final lastVisitedStr = prefs.getString('last_visited_grup_sekolah'); + + final guruRes = await GuruService.getGuru(); + final muridRes = await _muridService.getMurid(); + if (mounted) { + setState(() { + _lastVisitedGrupSekolah = lastVisitedStr != null ? DateTime.tryParse(lastVisitedStr) : null; + _listGuru = guruRes; + _listMurid = muridRes; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + Widget _buildBellIcon() { + if (Supabase.instance.client.auth.currentUser == null) { + return const SizedBox.shrink(); + } + + return StreamBuilder>>( + stream: DbGrupSekolah().getGrupSekolahStream(), + builder: (context, snapshot) { + int unreadCount = 0; + + if (snapshot.hasData && snapshot.data!.isNotEmpty) { + final currentUserId = Supabase.instance.client.auth.currentUser?.id; + for (final msg in snapshot.data!) { + final lastMessageTimeStr = msg['created_at']?.toString(); + final senderUserId = msg['user_id']?.toString(); + if (lastMessageTimeStr != null && senderUserId != currentUserId) { + final lastMessageTime = DateTime.tryParse(lastMessageTimeStr); + if (lastMessageTime != null) { + if (_lastVisitedGrupSekolah == null || lastMessageTime.isAfter(_lastVisitedGrupSekolah!)) { + unreadCount++; + } + } + } + } + } + + return Stack( + alignment: Alignment.center, + children: [ + IconButton( + icon: const Icon(Icons.notifications_outlined, color: Colors.white, size: 24), + tooltip: 'Notifikasi', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GrupSekolahP()), + ).then((_) { + _loadData(); + // Re-evaluate notification state after returning + setState(() {}); + }); + }, + ), + if (unreadCount > 0) + Positioned( + right: 8, + top: 8, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF2563EB), width: 1.5), + ), + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + // bottomNavigationBar: const BottomNav(selectedIndex: 1), + appBar: AppBar( + //toolbarHeight: 100, + titleSpacing: 20, + title: const Text('Pengumuman Sekolah'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: Color(0xFF2563EB), + elevation: 0, + actions: [ + //_buildBellIcon(), + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: IconButton( + icon: const Icon(Icons.refresh, + color: Colors.white), + onPressed: () { + setState(() { + _isLoading = true; + }); + _loadData(); + }, + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.symmetric(vertical: 10.0), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder( + valueListenable: NotificationFCM.hasUnread, + builder: (context, hasUnread, child) { + return Stack( + children: [ + CustomCard( + title: 'Grup Sekolah', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Forum', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Informasi & Diskusi', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () async { + final prefs = await SharedPreferences.getInstance(); + final nowStr = DateTime.now().toIso8601String(); + await prefs.setString('last_visited_grup_sekolah', nowStr); + if (mounted) { + setState(() { + _lastVisitedGrupSekolah = DateTime.tryParse(nowStr); + }); + NotifRedDot.setGrupSekolahUnread(false); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GrupSekolah()), + ).then((_) { + _loadData(); + }); + } + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text('List Guru', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, fontFamily: 'Inter')), + ), + const SizedBox(height: 10), + ..._listGuru.map((guru) { + final nama = guru['name'] ?? '-'; + final isWali = guru['wali'] == true; + final roleText = isWali ? 'Wali Kelas' : 'Guru Mapel'; + final kelas = guru['class_name']?['name_class'] ?? 'Belum ada kelas'; + final String guruId = guru['id_tabel']?.toString() ?? ''; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder>( + valueListenable: ChatPrivateNotification.unreadSenderIds, + builder: (context, unreadSenders, child) { + final bool hasUnread = unreadSenders.contains(guruId); + return Stack( + children: [ + CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + roleText, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + kelas, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + NotifRedDot.setChatPrivateSenderUnread(guruId, false); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPrivate( + receiverId: guruId, + receiverType: 'guru', + receiverName: nama, + ), + ), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ); + }).toList(), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text('List Murid', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, fontFamily: 'Inter')), + ), + const SizedBox(height: 10), + ..._listMurid.map((murid) { + final nama = murid['nama'] ?? '-'; + final kelas = murid['class_name']?['name_class'] ?? 'Belum ada kelas'; + final String muridId = murid['id_tabel']?.toString() ?? ''; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder>( + valueListenable: NotifRedDot.chatPrivateUnreadSenderIds, + builder: (context, unreadSenders, child) { + final bool hasUnread = unreadSenders.contains(muridId); + return Stack( + children: [ + CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Siswa', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + kelas, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + NotifRedDot.setChatPrivateSenderUnread(muridId, false); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPrivate( + receiverId: muridId, + receiverType: 'murid', + receiverName: nama, + ), + ), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ); + }).toList(), + ], + ), + ); + } +} diff --git a/lib/dashboard/a_admin/dashboard/class.dart b/lib/dashboard/a_admin/dashboard/class.dart new file mode 100644 index 0000000..7753898 --- /dev/null +++ b/lib/dashboard/a_admin/dashboard/class.dart @@ -0,0 +1,438 @@ +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/a_admin/database/db_kelas.dart'; +import 'package:apk/dashboard/a_admin/function/f_kelas/content_kelas.dart'; +import 'package:apk/dashboard/a_admin/database/db_namasekolah.dart'; +import 'package:apk/error_handler/connection.dart'; + +class DaftarKelas extends StatefulWidget { + const DaftarKelas({super.key}); + + @override + State createState() => _DaftarKelasState(); +} + +class _DaftarKelasState extends State { + List> allData = []; + bool isLoading = true; + String? searchQuery; + String namaSekolah = "Nama Sekolah"; + bool isSearchActive = false; + + @override + void initState() { + super.initState(); + fetchData(); + } + + Future fetchData() async { + if (!await ConnectionHandler.checkConnection()) { + if (mounted) { + setState(() => isLoading = false); + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () { + setState(() => isLoading = true); + fetchData(); + }, + ); + } + return; + } + + try { + final res = await ClassService.getClasses(); + final sekolah = await NamaSekolahService.getNamaSekolah() ?? "Nama Sekolah"; + + if (!mounted) return; + + setState(() { + allData = res ?? []; + namaSekolah = sekolah; + isLoading = false; + }); + } catch (e) { + if (kDebugMode) print(e); + + if (!mounted) return; + + setState(() { + isLoading = false; + }); + } + } + + String _safe(dynamic value) { + if (value == null || value.toString().isEmpty) return "-"; + return value.toString(); + } + + String _waliName(Map item) { + final guruData = item['guru']; + + if (guruData is List && guruData.isNotEmpty) { + final waliGuru = guruData.firstWhere( + (g) => g is Map && g['wali'] == true, + orElse: () => null, + ); + + if (waliGuru != null) { + return waliGuru['name']?.toString() ?? "-"; + } + + return guruData.first['name']?.toString() ?? "-"; + } + + return "-"; + } + + List> get _filteredData { + final query = (searchQuery ?? "").toLowerCase(); + + if (query.isEmpty) return allData; + + return allData.where((item) { + final name = _safe(item['name_class']).toLowerCase(); + final wali = _waliName(item).toLowerCase(); + final tahun = _safe(item['tahun']).toLowerCase(); + + return name.contains(query) || + wali.contains(query) || + tahun.contains(query); + }).toList(); + } + + int _muridCount(Map item) { + final muridData = item['murid']; + if (muridData is List) return muridData.length; + return 0; + } + + Color _levelColor(int level) { + switch (level) { + case 1: + return const Color(0xFF2563EB); + case 2: + return const Color(0xFF059669); + case 3: + return const Color(0xFF7C3AED); + case 4: + return const Color(0xFFDC2626); + case 5: + return const Color(0xFFEA580C); + case 6: + return const Color(0xFF0891B2); + default: + return const Color(0xFF64748B); + } + } + + Color _softLevelColor(int level) { + switch (level) { + case 1: + return const Color(0xFFDBEAFE); + case 2: + return const Color(0xFFD1FAE5); + case 3: + return const Color(0xFFEDE9FE); + case 4: + return const Color(0xFFFEE2E2); + case 5: + return const Color(0xFFFFEDD5); + case 6: + return const Color(0xFFCFFAFE); + default: + return const Color(0xFFF1F5F9); + } + } + + Widget _levelBadge(int level) { + final color = _levelColor(level); + return Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: _softLevelColor(level), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.25), width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.school_outlined, color: color, size: 18), + const SizedBox(height: 2), + Text( + level == 7 ? "LAIN" : "KLS $level", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ), + ); + } + + Widget _classCard(Map item) { + final level = int.tryParse(item['id_class']?.toString() ?? "") ?? 7; + final nameClass = _safe(item['name_class']); + final wali = _waliName(item); + final tahun = _safe(item['tahun']); + final muridCount = _muridCount(item); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: SizedBox( + height: 100, + child: Stack( + alignment: Alignment.center, + children: [ + CustomCard( + title: "", + showIcon: false, + height: 100, + backgroundColor: Colors.white, + borderColor: const Color(0xFFE8EEF6), + borderWidth: 1, + borderRadius: 14, + margin: EdgeInsets.zero, + padding: EdgeInsets.zero, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ContentKelas(classData: item), + ), + ); + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _levelBadge(level), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nameClass, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + "Wali: $wali", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), + ), + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.groups_2_outlined, size: 14), + const SizedBox(width: 4), + Text("$muridCount murid", + style: const TextStyle(fontSize: 12)), + const SizedBox(width: 10), + const Icon(Icons.calendar_month_outlined, size: 14), + const SizedBox(width: 4), + Flexible( + child: Text( + tahun, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ], + ), + ), + const Icon(Icons.chevron_right, size: 20, color: Color(0xFFCBD5E1)), + ], + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final dataCount = allData.length; + final visibleData = _filteredData; + + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + + appBar: AppBar( + elevation: 0, + backgroundColor: const Color(0xFF2563EB), + automaticallyImplyLeading: false, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(0), + child: Container( + height: 45, + width: double.infinity, + color: const Color(0xFF2563EB), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Daftar Kelas", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + "$dataCount kelas terdaftar", + style: const TextStyle( + fontSize: 11, + color: Colors.white70, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + onPressed: () { + setState(() => isLoading = true); + fetchData(); + }, + ), + ], + ), + ), + ), + ), + + body: Column( + children: [ + // SEARCH BAR + Container( + height: 70, + color: const Color(0xFF2563EB), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: isSearchActive + ? TextField( + autofocus: true, + onChanged: (value) { + setState(() => searchQuery = value); + }, + decoration: InputDecoration( + hintText: "Cari kelas atau wali kelas...", + hintStyle: const TextStyle( + fontSize: 14, + color: Color(0xFFCCCCCC), + ), + filled: true, + fillColor: Colors.grey[200], + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + prefixIcon: IconButton( + icon: const Icon(Icons.arrow_back, size: 18), + onPressed: () { + setState(() { + isSearchActive = false; + searchQuery = null; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ) + : Row( + children: [ + Transform.translate( + offset: const Offset(-12, 0), + child: IconButton( + icon: const Icon(Icons.search, color: Colors.white), + onPressed: () { + setState(() { + isSearchActive = true; + }); + }, + ), + ), + Expanded( + child: Text( + namaSekolah, + textAlign: TextAlign.right, + style: const TextStyle( + fontFamily: "Inter", + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + + const SizedBox(height: 0), + + // LIST + Expanded( + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: fetchData, + child: visibleData.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: const Center(child: Text("Belum ada kelas")), + ), + ], + ) + : ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 10, bottom: 20), + itemCount: visibleData.length, + itemBuilder: (context, index) { + return _classCard(visibleData[index]); + }, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/database/auth.dart b/lib/dashboard/a_admin/database/auth.dart new file mode 100644 index 0000000..155be6e --- /dev/null +++ b/lib/dashboard/a_admin/database/auth.dart @@ -0,0 +1,94 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AuthService { + final supabase = Supabase.instance.client; + + Future signUp( + String email, + String password, + ) async { + return await supabase.auth.signUp( + email: email, + password: password, + emailRedirectTo: 'io.supabase.flutter://login-callback', + ); + } + + Future signIn( + String email, + String password, + ) async { + return await supabase.auth.signInWithPassword( + email: email, + password: password, + ); + } + + Future insertUser({ + required String id, + required String email, + }) async { + await supabase.from('user_admin').insert({ + 'id': id, + 'email': email, + 'name': null, + 'nomor_induk': null, + 'role': 'admin', + }); + } + + Future?> getProfile() async { + final userId = supabase.auth.currentUser?.id; + + if (userId == null) return null; + + final data = await supabase + .from('user_admin') + .select() + .eq('id', userId) + .single(); + + return data; + } + + Future updateProfile({ + required String name, + required String nomorInduk, + }) async { + final userId = supabase.auth.currentUser?.id; + + await supabase.from('user_admin').update({ + 'name': name, + 'nomor_induk': nomorInduk, + }).eq('id', userId!); + } + + Future signOut() async { + await supabase.auth.signOut(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('user_email'); + } + +Future verifyOtp( + String email, + String otp, +) async { + return await supabase.auth.verifyOTP( + email: email, + token: otp, + type: OtpType.signup, + ); +} + +Future deleteUserPublic(String email) async { + await supabase + .from('user_admin') + .delete() + .eq('email', email); +} + + Session? get session => supabase.auth.currentSession; + User? get currentUser => supabase.auth.currentUser; + String? get userId => supabase.auth.currentUser?.id; +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/database/db_absen.dart b/lib/dashboard/a_admin/database/db_absen.dart new file mode 100644 index 0000000..62bbf09 --- /dev/null +++ b/lib/dashboard/a_admin/database/db_absen.dart @@ -0,0 +1,48 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +class AbsenService { + static final supabase = Supabase.instance.client; + + /// Naik tingkat kelas: + /// 1. Pindahkan murid-murid ke kelas tujuan (targetClassId) + /// 2. Hapus seluruh data absen murid-murid tersebut + /// 3. Hapus seluruh chat private murid-murid tersebut + static Future promoteClass({ + required String sourceClassId, + required String targetClassId, + required List studentIds, + }) async { + if (studentIds.isEmpty) return; + + // 1. Pindahkan murid ke kelas tujuan + print("promoteClass Step 1: Updating murid.id_class to targetClassId..."); + await supabase + .from('murid') + .update({'id_class': targetClassId}) + .inFilter('id_tabel', studentIds); + print("promoteClass Step 1 completed."); + + // 2. Hapus data absen murid-murid tersebut + print("promoteClass Step 2: Deleting murid records from 'absen'..."); + await supabase + .from('absen') + .delete() + .inFilter('id_murid', studentIds); + print("promoteClass Step 2 completed."); + + // 3. Hapus data chat private murid-murid tersebut (baik sebagai pengirim maupun penerima) + print("promoteClass Step 3a: Deleting chat_private where pengirim_murid in studentIds..."); + await supabase + .from('chat_private') + .delete() + .inFilter('pengirim_murid', studentIds); + print("promoteClass Step 3a completed."); + + print("promoteClass Step 3b: Deleting chat_private where penerima_murid in studentIds..."); + await supabase + .from('chat_private') + .delete() + .inFilter('penerima_murid', studentIds); + print("promoteClass Step 3b completed."); + } +} diff --git a/lib/dashboard/a_admin/database/db_grup_sekolah.dart b/lib/dashboard/a_admin/database/db_grup_sekolah.dart new file mode 100644 index 0000000..53d3fb9 --- /dev/null +++ b/lib/dashboard/a_admin/database/db_grup_sekolah.dart @@ -0,0 +1,137 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DbGrupSekolah { + static final supabase = Supabase.instance.client; + + static String get userId { + final id = supabase.auth.currentUser?.id; + if (id == null) { + throw Exception('User belum login'); + } + return id; + } + + // Determine current user's role and corresponding table ID + static Future?> getCurrentUserRoleAndId() async { + try { + // 1. Cek Admin + final adminRes = await supabase + .from('user_admin') + .select('id') + .eq('id', userId) + .maybeSingle(); + if (adminRes != null) { + return {'role': 'admin', 'id': adminRes['id'].toString()}; + } + + // 2. Cek Guru + final guruRes = await supabase + .from('guru') + .select('id_tabel') + .eq('user_id', userId) + .or('status_akun.is.null,status_akun.eq.true') + .maybeSingle(); + if (guruRes != null) { + return {'role': 'guru', 'id': guruRes['id_tabel'].toString()}; + } + + // 3. Cek Murid + final muridRes = await supabase + .from('murid') + .select('id_tabel') + .eq('user_id', userId) + .or('status_akun.is.null,status_akun.eq.true') + .maybeSingle(); + if (muridRes != null) { + return {'role': 'murid', 'id': muridRes['id_tabel'].toString()}; + } + } catch (e) { + if (kDebugMode) print('Check Role Error: $e'); + } + return null; + } + + // Ambil semua nama (Admin, Guru, Murid) untuk ditampilkan di chat + static Future> fetchAllNames() async { + final Map namesMap = {}; + try { + // Fetch Admin names + final admins = await supabase + .from('user_admin') + .select('id, name') + .eq('id', userId); // user_admin table uses id as user_id + + for (var a in admins) { + if (a['id'] != null && a['name'] != null) { + namesMap[a['id'].toString()] = a['name'].toString(); + } + } + + // Fetch Guru names + final gurus = await supabase + .from('guru') + .select('id_tabel, name') + .eq('user_id', userId) + .or('status_akun.is.null,status_akun.eq.true'); + + for (var g in gurus) { + if (g['id_tabel'] != null && g['name'] != null) { + namesMap[g['id_tabel'].toString()] = g['name'].toString(); + } + } + + // Fetch Murid names + final murids = await supabase + .from('murid') + .select('id_tabel, nama') + .eq('user_id', userId) + .or('status_akun.is.null,status_akun.eq.true'); + + for (var m in murids) { + if (m['id_tabel'] != null && m['nama'] != null) { + namesMap[m['id_tabel'].toString()] = m['nama'].toString(); + } + } + } catch (e) { + if (kDebugMode) print('Fetch Names Error: $e'); + } + return namesMap; + } + + // Stream chat global + Stream>> getGrupSekolahStream() { + return supabase + .from('grup_sekolah') + .stream(primaryKey: ['id_tabel']) + .eq('user_id', userId) + .order('created_at', ascending: true); + } + + // Kirim Pesan + Future sendMessage({ + required String text, + required String senderId, + required String role, + }) async { + try { + final Map insertData = { + 'text': text, + 'user_id': userId, + }; + + if (role == 'admin') { + insertData['pengirim_admin'] = senderId; + } else if (role == 'guru') { + insertData['pengirim_guru'] = senderId; + } else if (role == 'murid') { + insertData['pengirim_murid'] = senderId; + } + + await supabase.from('grup_sekolah').insert(insertData); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } +} diff --git a/lib/dashboard/a_admin/database/db_guru.dart b/lib/dashboard/a_admin/database/db_guru.dart new file mode 100644 index 0000000..ce9abf4 --- /dev/null +++ b/lib/dashboard/a_admin/database/db_guru.dart @@ -0,0 +1,330 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class GuruService { + static final supabase = Supabase.instance.client; + + static String get userId { + final id = supabase.auth.currentUser?.id; + if (id == null) { + throw Exception('User belum login'); + } + return id; + } + + // =============================== + // 🔹 GET DATA GURU + // =============================== + static Future>> getGuru() async { + try { + final response = await supabase + .from('guru') + .select(''' + id_tabel, + nik, + name, + bidang, + wali, + id_class, + created_at, + class_name!guru_id_class_fkey ( + id_tabel, + name_class + ) + ''') + .eq('user_id', userId) + .or('status_akun.is.null,status_akun.eq.true') + .order('name', ascending: true); + + return List>.from(response); + } catch (e) { + if (kDebugMode) { + print('GET GURU ERROR: $e'); + } + throw Exception('Gagal memuat data guru: $e'); + } + } + + // =============================== + // 🔹 GET DATA KELAS + // =============================== + static Future>> getKelas() async { + try { + final response = await supabase + .from('class_name') + .select('id_tabel, name_class') + .eq('user_id', userId) + .order('name_class'); + + return List>.from(response); + } catch (e) { + if (kDebugMode) { + print('GET KELAS ERROR: $e'); + } + return []; + } + } + + // =============================== + // 🔹 CHECK WALI KELAS + // =============================== + static Future checkWaliExists(String idClass, {String? excludeGuruId}) async { + try { + var query = supabase + .from('guru') + .select('id_tabel') + .eq('user_id', userId) + .eq('id_class', idClass) + .eq('wali', true) + .or('status_akun.is.null,status_akun.eq.true'); + + if (excludeGuruId != null) { + query = query.neq('id_tabel', excludeGuruId); + } + + final res = await query.limit(1); + return res.isNotEmpty; + } catch (e) { + if (kDebugMode) { + print('CHECK WALI ERROR: $e'); + } + throw Exception('Gagal mengecek status wali kelas'); + } + } + + static Future checkNikDuplicate(String nik, {String? excludeGuruId}) async { + try { + var query = supabase + .from('guru') + .select('id_tabel, status_akun') + .eq('user_id', userId) + .eq('nik', nik); + + if (excludeGuruId != null) { + query = query.neq('id_tabel', excludeGuruId); + } + + final res = await query.limit(1); + if (res.isEmpty) return null; + + final bool statusAkun = res.first['status_akun'] == true; + return statusAkun ? 'active' : 'deleted'; + } catch (e) { + if (kDebugMode) { + print('CHECK NIK DUPLICATE ERROR: $e'); + } + throw Exception('Gagal mengecek NIK guru'); + } + } + + static Future checkNameDuplicate(String name, {String? excludeGuruId}) async { + try { + var query = supabase + .from('guru') + .select('id_tabel, status_akun') + .eq('user_id', userId) + .eq('name', name); + + if (excludeGuruId != null) { + query = query.neq('id_tabel', excludeGuruId); + } + + final res = await query.limit(1); + if (res.isEmpty) return null; + + final bool statusAkun = res.first['status_akun'] == true; + return statusAkun ? 'active' : 'deleted'; + } catch (e) { + if (kDebugMode) { + print('CHECK NAME DUPLICATE ERROR: $e'); + } + throw Exception('Gagal mengecek nama guru'); + } + } + + // =============================== + // 🔹 INSERT DATA GURU + // =============================== + static Future tambahGuru({ + required String nik, + required String name, + String? bidang, + String? idClass, + required bool wali, + }) async { + try { + if (wali && idClass == null) { + throw Exception('Kelas harus dipilih jika menjadi Wali Kelas'); + } + + final nikDup = await checkNikDuplicate(nik); + if (nikDup == 'active') { + throw Exception('NIK sudah terdaftar!'); + } else if (nikDup == 'deleted') { + throw Exception('NIK sudah terdaftar di Tempat Sampah! Silakan periksa Tempat Sampah untuk memulihkannya.'); + } + + final nameDup = await checkNameDuplicate(name); + if (nameDup == 'active') { + throw Exception('Nama guru sudah terdaftar!'); + } else if (nameDup == 'deleted') { + throw Exception('Nama guru sudah terdaftar di Tempat Sampah! Silakan periksa Tempat Sampah untuk memulihkannya.'); + } + + if (wali && idClass != null) { + final exists = await checkWaliExists(idClass); + if (exists) { + throw Exception('Kelas ini sudah memiliki wali kelas!'); + } + } + + await supabase.from('guru').insert({ + 'user_id': userId, + 'nik': nik, + 'name': name, + 'bidang': bidang, + 'id_class': idClass, + 'wali': wali, + 'status_akun': true, + }); + } catch (e) { + if (kDebugMode) { + print('ADD GURU ERROR: $e'); + } + rethrow; + } + } + + // =============================== + // 🔹 UPDATE DATA GURU + // =============================== + static Future updateGuru({ + required String idTabel, + required String nik, + required String name, + String? bidang, + String? idClass, + required bool wali, + }) async { + try { + if (wali && idClass == null) { + throw Exception('Kelas harus dipilih jika menjadi Wali Kelas'); + } + + final nikDup = await checkNikDuplicate(nik, excludeGuruId: idTabel); + if (nikDup == 'active') { + throw Exception('NIK sudah terdaftar!'); + } else if (nikDup == 'deleted') { + throw Exception('NIK sudah terdaftar di Tempat Sampah! Silakan periksa Tempat Sampah untuk memulihkannya.'); + } + + final nameDup = await checkNameDuplicate(name, excludeGuruId: idTabel); + if (nameDup == 'active') { + throw Exception('Nama guru sudah terdaftar!'); + } else if (nameDup == 'deleted') { + throw Exception('Nama guru sudah terdaftar di Tempat Sampah! Silakan periksa Tempat Sampah untuk memulihkannya.'); + } + + if (wali && idClass != null) { + final exists = await checkWaliExists(idClass, excludeGuruId: idTabel); + if (exists) { + throw Exception('Kelas ini sudah memiliki wali kelas!'); + } + } + + await supabase.from('guru').update({ + 'nik': nik, + 'name': name, + 'bidang': bidang, + 'id_class': idClass, + 'wali': wali, + }).eq('id_tabel', idTabel).eq('user_id', userId); + } catch (e) { + if (kDebugMode) { + print('UPDATE GURU ERROR: $e'); + } + rethrow; + } + } + + // =============================== + // 🔹 DELETE DATA GURU + // =============================== + static Future deleteGuru(String idTabel) async { + try { + await supabase.from('guru').update({ + 'status_akun': false, + 'id_class': null, + 'wali': false, + }).eq('id_tabel', idTabel).eq('user_id', userId); + } catch (e) { + if (kDebugMode) { + print('DELETE GURU ERROR: $e'); + } + rethrow; + } + } + + // =============================== + // 🔹 GET DELETED GURU + // =============================== + static Future>> getDeletedGuru() async { + try { + final response = await supabase + .from('guru') + .select(''' + id_tabel, + nik, + name, + bidang, + wali, + id_class, + created_at, + class_name!guru_id_class_fkey ( + id_tabel, + name_class + ) + ''') + .eq('user_id', userId) + .eq('status_akun', false) + .order('name', ascending: true); + + return List>.from(response); + } catch (e) { + if (kDebugMode) { + print('GET DELETED GURU ERROR: $e'); + } + throw Exception('Gagal memuat data guru yang dihapus: $e'); + } + } + + // =============================== + // 🔹 RESTORE DATA GURU + // =============================== + static Future restoreGuru(String idTabel) async { + try { + await supabase.from('guru').update({ + 'status_akun': true, + }).eq('id_tabel', idTabel).eq('user_id', userId); + } catch (e) { + if (kDebugMode) { + print('RESTORE GURU ERROR: $e'); + } + rethrow; + } + } + + // =============================== + // 🔹 DELETE DATA GURU PERMANENTLY + // =============================== + static Future deleteGuruPermanently(String idTabel) async { + try { + await supabase.from('guru').delete().eq('id_tabel', idTabel).eq('user_id', userId); + } catch (e) { + if (kDebugMode) { + print('DELETE GURU PERMANENTLY ERROR: $e'); + } + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/database/db_kelas.dart b/lib/dashboard/a_admin/database/db_kelas.dart new file mode 100644 index 0000000..8b70aca --- /dev/null +++ b/lib/dashboard/a_admin/database/db_kelas.dart @@ -0,0 +1,212 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +class ClassService { + + static final supabase = Supabase.instance.client; + + static Future>> getClasses() async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return []; + + try { + final response = await supabase + .from('class_name') + .select(''' + *, + guru!guru_id_class_fkey(name, wali, status_akun), + murid!murid_id_class_fkey(id_tabel, status_akun) + ''') + .eq('user_id', userId) + .order('id_class'); + + final list = List>.from(response); + for (var cls in list) { + if (cls['guru'] != null) { + final rawGurus = cls['guru'] as List; + cls['guru'] = rawGurus.where((g) => g['status_akun'] != false).toList(); + } + if (cls['murid'] != null) { + final rawMurids = cls['murid'] as List; + cls['murid'] = rawMurids.where((m) => m['status_akun'] != false).toList(); + } + } + return list; + } catch (e) { + print("Error fetching with relations: $e"); + + // Fallback if the relationship queries throw error (e.g. FK not yet reflected) + try { + final response2 = await supabase + .from('class_name') + .select('*') + .eq('user_id', userId) + .order('id_class'); + return List>.from(response2); + } catch (eFallback) { + print("Fallback fetching failed: $eFallback"); + return []; + } + } + } + + static Future>> getGuru() async { + final response = await supabase + .from('guru') + .select('id_tabel, name') + .or('status_akun.is.null,status_akun.eq.true') + .order('name'); + return List>.from(response); + } + + static Future>> getMurid() async { + final response = await supabase + .from('murid') + .select('id_tabel, nama') + .or('status_akun.is.null,status_akun.eq.true') + .order('nama'); + return List>.from(response); + } + + static Future>> getMuridByClass(String idTabelClass) async { + final response = await supabase + .from('murid') + .select('id_tabel, nis, nama, gender') + .eq('id_class', idTabelClass) + .or('status_akun.is.null,status_akun.eq.true') + .order('nama'); + return List>.from(response); + } + + static Future>> getGuruByClass(String idTabelClass) async { + final response = await supabase + .from('guru') + .select('id_tabel, nik, name, bidang, wali') + .eq('id_class', idTabelClass) + .or('status_akun.is.null,status_akun.eq.true') + .order('wali', ascending: false); // Wali kelas will be at the top + return List>.from(response); + } + + static Future tambahKelas({ + required num idClass, + required String nameClass, + String? idGuru, + String? idMurid, + String? tahun, + }) async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) throw Exception("User not logged in"); + + await supabase.from('class_name').insert({ + 'id_class': idClass, + 'name_class': nameClass, + 'user_id': userId, + if (idGuru != null) 'id_guru': idGuru, + if (idMurid != null) 'id_murid': idMurid, + if (tahun != null) 'tahun': tahun, + }); + } + + static Future updateKelas({ + required String idTabel, + required num idClass, + required String nameClass, + String? tahun, + }) async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) throw Exception("User not logged in"); + + await supabase.from('class_name').update({ + 'id_class': idClass, + 'name_class': nameClass, + 'tahun': tahun, // can be null to clear + }).eq('id_tabel', idTabel).eq('user_id', userId); + } + + + static Future deleteKelas(String idTabel) async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) throw Exception("User not logged in"); + + // Cari semua murid yang ada di kelas ini + final murids = await supabase + .from('murid') + .select('id_tabel') + .eq('id_class', idTabel) + .eq('user_id', userId); + + if (murids.isNotEmpty) { + final listIdMurid = murids.map((m) => m['id_tabel'].toString()).toList(); + // Hapus data absen untuk semua murid di kelas ini + await supabase.from('absen').delete().inFilter('id_murid', listIdMurid); + } + + // Remove relationships first: set id_class to null for guru and murid + await supabase.from('guru').update({'id_class': null, 'wali': false}).eq('id_class', idTabel).eq('user_id', userId); + await supabase.from('murid').update({'id_class': null}).eq('id_class', idTabel).eq('user_id', userId); + + // Then delete the class + await supabase.from('class_name').delete().eq('id_tabel', idTabel).eq('user_id', userId); + } + + + static Future>> getGuruUnassigned() async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return []; + final response = await supabase + .from('guru') + .select('id_tabel, nik, name, bidang') + .filter('id_class', 'is', null) + .eq('user_id', userId) + .or('status_akun.is.null,status_akun.eq.true') + .order('name'); + return List>.from(response); + } + + static Future>> getMuridUnassigned() async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return []; + final response = await supabase + .from('murid') + .select('id_tabel, nis, nama, gender') + .filter('id_class', 'is', null) + .eq('user_id', userId) + .or('status_akun.is.null,status_akun.eq.true') + .order('nama'); + return List>.from(response); + } + + static Future assignGuruToClass(String idGuru, String idTabelClass, {bool isWali = false}) async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) throw Exception("User not logged in"); + await supabase.from('guru').update({ + 'id_class': idTabelClass, + 'wali': isWali, + }).eq('id_tabel', idGuru).eq('user_id', userId); + } + + static Future removeGuruFromClass(String idGuru) async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) throw Exception("User not logged in"); + await supabase.from('guru').update({ + 'id_class': null, + 'wali': false, + }).eq('id_tabel', idGuru).eq('user_id', userId); + } + + static Future assignMuridToClass(String idMurid, String idTabelClass) async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) throw Exception("User not logged in"); + await supabase.from('murid').update({ + 'id_class': idTabelClass, + }).eq('id_tabel', idMurid).eq('user_id', userId); + } + + static Future removeMuridFromClass(String idMurid) async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) throw Exception("User not logged in"); + await supabase.from('murid').update({ + 'id_class': null, + }).eq('id_tabel', idMurid).eq('user_id', userId); + } +} diff --git a/lib/dashboard/a_admin/database/db_namasekolah.dart b/lib/dashboard/a_admin/database/db_namasekolah.dart new file mode 100644 index 0000000..6e21160 --- /dev/null +++ b/lib/dashboard/a_admin/database/db_namasekolah.dart @@ -0,0 +1,87 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +class NamaSekolahService { + static final supabase = Supabase.instance.client; + + static Future getNamaSekolah() async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + try { + final response = await supabase + .from('nama_sekolah') + .select('sekolah') + .eq('user_id', userId) + .maybeSingle(); + + if (response != null) { + return response['sekolah'] as String; + } + return null; + } catch (e) { + print("Error fetching school name: $e"); + return null; + } + } + + static Future?> getSchoolDetails() async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + try { + final response = await supabase + .from('nama_sekolah') + .select('sekolah, wilayah') + .eq('user_id', userId) + .maybeSingle(); + + if (response != null) { + return { + 'sekolah': response['sekolah'] as String?, + 'wilayah': response['wilayah'] as String?, + }; + } + return null; + } catch (e) { + print("Error fetching school details: $e"); + return null; + } + } + + static Future updateNamaSekolah(String newName, String? newWilayah) async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) throw Exception("User not logged in"); + + try { + final existing = await supabase + .from('nama_sekolah') + .select('id_tabel') + .eq('user_id', userId) + .maybeSingle(); + + if (existing != null) { + // Update existing record + await supabase + .from('nama_sekolah') + .update({ + 'sekolah': newName, + 'wilayah': newWilayah, + }) + .eq('id_tabel', existing['id_tabel']) + .eq('user_id', userId); + } else { + // Insert new record + await supabase + .from('nama_sekolah') + .insert({ + 'user_id': userId, + 'sekolah': newName, + 'wilayah': newWilayah, + }); + } + } catch (e) { + print("Error updating school name: $e"); + rethrow; + } + } +} diff --git a/lib/dashboard/a_admin/database/db_pengumuman.dart b/lib/dashboard/a_admin/database/db_pengumuman.dart new file mode 100644 index 0000000..e57cbbe --- /dev/null +++ b/lib/dashboard/a_admin/database/db_pengumuman.dart @@ -0,0 +1,58 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +/* +create table public.pengumuman ( + id_tabel uuid not null default gen_random_uuid (), + created_at timestamp with time zone not null default now(), + user_id uuid null, + title text not null, + tanggal_mulai date not null, + tanggal_selesai date not null, + keterangan text null, + id_guru uuid null, + constraint pengumuman_pkey primary key (id_tabel), + constraint pengumuman_id_guru_fkey foreign KEY (id_guru) references guru (id_tabel), + constraint pengumuman_user_id_fkey foreign KEY (user_id) references auth.users (id) +) TABLESPACE pg_default; +*/ + +class DbPengumuman { + static final supabase = Supabase.instance.client; + + static String get userId { + final id = supabase.auth.currentUser?.id; + if (id == null) throw Exception('User belum login'); + return id; + } + + // Insert pengumuman baru + static Future> insertPengumuman({ + required String title, + required String tanggalMulai, + required String tanggalSelesai, + String? keterangan, + String? idGuru, + }) async { + final data = { + 'user_id': userId, + 'title': title, + 'tanggal_mulai': tanggalMulai, + 'tanggal_selesai': tanggalSelesai, + if (keterangan != null && keterangan.isNotEmpty) 'keterangan': keterangan, + if (idGuru != null && idGuru.isNotEmpty) 'id_guru': idGuru, + }; + + final res = await supabase.from('pengumuman').insert(data).select().single(); + return res; + } + + // Get pengumuman berdasarkan ID + static Future?> getPengumumanById(String id) async { + try { + final res = await supabase.from('pengumuman').select().eq('id_tabel', id).eq('user_id', userId).maybeSingle(); + return res; + } catch (e) { + return null; + } + } +} diff --git a/lib/dashboard/a_admin/database/db_pesan_private.dart b/lib/dashboard/a_admin/database/db_pesan_private.dart new file mode 100644 index 0000000..5aa9771 --- /dev/null +++ b/lib/dashboard/a_admin/database/db_pesan_private.dart @@ -0,0 +1,97 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DbPesanPrivate { + static final supabase = Supabase.instance.client; + + static String get userId { + final id = supabase.auth.currentUser?.id; + if (id == null) { + throw Exception('User belum login'); + } + return id; + } + + // Get Admin ID (for the current logged in user) + Future getAdminId() async { + try { + final res = await supabase + .from('user_admin') + .select('id') + .eq('id', userId) + .single(); + return res['id']?.toString(); + } catch (e) { + if (kDebugMode) print('Get Admin ID Error: $e'); + return null; + } + } + + // Stream chat between Admin and Guru + Stream>> getChatWithGuruStream(String idAdmin, String idGuru) { + return supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .eq('user_id', userId) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_admin'] == idAdmin && e['penerima_guru'] == idGuru) || + (e['pengirim_guru'] == idGuru && e['penerima_admin'] == idAdmin)) + .map((e) => e as Map) + .toList()); + } + + // Stream chat between Admin and Murid + Stream>> getChatWithMuridStream(String idAdmin, String idMurid) { + return supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .eq('user_id', userId) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_admin'] == idAdmin && e['penerima_murid'] == idMurid) || + (e['pengirim_murid'] == idMurid && e['penerima_admin'] == idAdmin)) + .map((e) => e as Map) + .toList()); + } + + // Send Message to Guru + Future sendMessageToGuru({ + required String text, + required String idAdmin, + required String idGuru, + }) async { + try { + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_admin': idAdmin, + 'penerima_guru': idGuru, + 'user_id': userId, + }); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } + + // Send Message to Murid + Future sendMessageToMurid({ + required String text, + required String idAdmin, + required String idMurid, + }) async { + try { + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_admin': idAdmin, + 'penerima_murid': idMurid, + 'user_id': userId, + }); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } +} diff --git a/lib/dashboard/a_admin/database/db_siswa.dart b/lib/dashboard/a_admin/database/db_siswa.dart new file mode 100644 index 0000000..6d45444 --- /dev/null +++ b/lib/dashboard/a_admin/database/db_siswa.dart @@ -0,0 +1,313 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class MuridService { + final supabase = Supabase.instance.client; + + String get userId { + final id = supabase.auth.currentUser?.id; + if (id == null) { + throw Exception('User belum login'); + } + return id; + } + + // =============================== + // 🔹 GET DATA MURID + // =============================== + Future>> getMurid() async { + try { + final res = await supabase + .from('murid') + .select(''' + id_tabel, + nis, + nama, + id_class, + gender, + tanggal_lahir, + alamat, + orang_tua, + no_tele, + created_at, + class_name!murid_id_class_fkey ( + id_tabel, + name_class + ) + ''') + .eq('user_id', userId) + .or('status_akun.is.null,status_akun.eq.true') + .order('nis', ascending: true); + + return List>.from(res); + } catch (e) { + if (kDebugMode) { + print('GET MURID ERROR: $e'); + } + throw Exception('Gagal memuat data murid: $e'); + } + } + + // =============================== + // 🔹 GET DATA KELAS + // =============================== + Future>> getKelas() async { + try { + final res = await supabase + .from('class_name') + .select('id_tabel, name_class') + .eq('user_id', userId) + .order('name_class', ascending: true); + + return List>.from(res); + } catch (e) { + if (kDebugMode) { + print('GET KELAS ERROR: $e'); + } + return []; + } + } + + Future checkNisDuplicate(String nis, {String? excludeMuridId}) async { + try { + var query = supabase + .from('murid') + .select('id_tabel, status_akun') + .eq('user_id', userId) + .eq('nis', nis); + + if (excludeMuridId != null) { + query = query.neq('id_tabel', excludeMuridId); + } + + final res = await query.limit(1); + if (res.isEmpty) return null; + + final bool statusAkun = res.first['status_akun'] == true; + return statusAkun ? 'active' : 'deleted'; + } catch (e) { + if (kDebugMode) { + print('CHECK NIS DUPLICATE ERROR: $e'); + } + throw Exception('Gagal mengecek NIS murid'); + } + } + + Future checkNamaDuplicate(String nama, {String? excludeMuridId}) async { + try { + var query = supabase + .from('murid') + .select('id_tabel, status_akun') + .eq('user_id', userId) + .eq('nama', nama); + + if (excludeMuridId != null) { + query = query.neq('id_tabel', excludeMuridId); + } + + final res = await query.limit(1); + if (res.isEmpty) return null; + + final bool statusAkun = res.first['status_akun'] == true; + return statusAkun ? 'active' : 'deleted'; + } catch (e) { + if (kDebugMode) { + print('CHECK NAMA DUPLICATE ERROR: $e'); + } + throw Exception('Gagal mengecek nama murid'); + } + } + + // =============================== + // 🔹 INSERT DATA MURID + // =============================== + Future addMurid({ + required String nis, + required String nama, + String? idClass, + String? gender, + DateTime? tanggalLahir, + String? alamat, + String? orangTua, + num? noTele, + }) async { + try { + final nisDup = await checkNisDuplicate(nis); + if (nisDup == 'active') { + throw Exception('NIS sudah terdaftar!'); + } else if (nisDup == 'deleted') { + throw Exception('NIS sudah terdaftar di Tempat Sampah! Silakan periksa Tempat Sampah untuk memulihkannya.'); + } + + final namaDup = await checkNamaDuplicate(nama); + if (namaDup == 'active') { + throw Exception('Nama murid sudah terdaftar!'); + } else if (namaDup == 'deleted') { + throw Exception('Nama murid sudah terdaftar di Tempat Sampah! Silakan periksa Tempat Sampah untuk memulihkannya.'); + } + + await supabase.from('murid').insert({ + 'user_id': userId, + 'nis': nis, + 'nama': nama, + 'id_class': idClass, + 'gender': gender, + 'tanggal_lahir': tanggalLahir?.toIso8601String().split('T').first, + 'alamat': alamat, + 'orang_tua': orangTua, + 'no_tele': noTele, + 'status_akun': true, + }); + } catch (e) { + if (kDebugMode) { + print('ADD MURID ERROR: $e'); + } + rethrow; + } + } + + // =============================== + // 🔹 UPDATE DATA MURID + // pakai primary key id_tabel + // =============================== + Future updateMurid({ + required String idTabel, + required String nis, + required String nama, + String? idClass, + String? gender, + DateTime? tanggalLahir, + String? alamat, + String? orangTua, + num? noTele, + }) async { + try { + final nisDup = await checkNisDuplicate(nis, excludeMuridId: idTabel); + if (nisDup == 'active') { + throw Exception('NIS sudah terdaftar!'); + } else if (nisDup == 'deleted') { + throw Exception('NIS sudah terdaftar di Tempat Sampah! Silakan periksa Tempat Sampah untuk memulihkannya.'); + } + + final namaDup = await checkNamaDuplicate(nama, excludeMuridId: idTabel); + if (namaDup == 'active') { + throw Exception('Nama murid sudah terdaftar!'); + } else if (namaDup == 'deleted') { + throw Exception('Nama murid sudah terdaftar di Tempat Sampah! Silakan periksa Tempat Sampah untuk memulihkannya.'); + } + + await supabase + .from('murid') + .update({ + 'nis': nis, + 'nama': nama, + 'id_class': idClass, + 'gender': gender, + 'tanggal_lahir': + tanggalLahir?.toIso8601String().split('T').first, + 'alamat': alamat, + 'orang_tua': orangTua, + 'no_tele': noTele, + }) + .eq('id_tabel', idTabel) + .eq('user_id', userId); + } catch (e) { + if (kDebugMode) { + print('UPDATE MURID ERROR: $e'); + } + rethrow; + } + } + + // =============================== + // 🔹 DELETE DATA MURID + // pakai primary key id_tabel + // =============================== + Future deleteMurid(String idTabel) async { + try { + await supabase + .from('murid') + .update({ + 'status_akun': false, + 'id_class': null, + }) + .eq('id_tabel', idTabel) + .eq('user_id', userId); + } catch (e) { + if (kDebugMode) { + print('DELETE MURID ERROR: $e'); + } + rethrow; + } + } + + // =============================== + // 🔹 GET DELETED MURID + // =============================== + Future>> getDeletedMurid() async { + try { + final res = await supabase + .from('murid') + .select(''' + id_tabel, + nis, + nama, + id_class, + gender, + tanggal_lahir, + alamat, + orang_tua, + no_tele, + created_at, + class_name!murid_id_class_fkey ( + id_tabel, + name_class + ) + ''') + .eq('user_id', userId) + .eq('status_akun', false) + .order('nis', ascending: true); + + return List>.from(res); + } catch (e) { + if (kDebugMode) { + print('GET DELETED MURID ERROR: $e'); + } + throw Exception('Gagal memuat data murid yang dihapus: $e'); + } + } + + // =============================== + // 🔹 RESTORE DATA MURID + // =============================== + Future restoreMurid(String idTabel) async { + try { + await supabase + .from('murid') + .update({ + 'status_akun': true, + }) + .eq('id_tabel', idTabel) + .eq('user_id', userId); + } catch (e) { + if (kDebugMode) { + print('RESTORE MURID ERROR: $e'); + } + rethrow; + } + } + + // =============================== + // 🔹 DELETE DATA MURID PERMANENTLY + // =============================== + Future deleteMuridPermanently(String idTabel) async { + try { + await supabase.from('murid').delete().eq('id_tabel', idTabel).eq('user_id', userId); + } catch (e) { + if (kDebugMode) { + print('DELETE MURID PERMANENTLY ERROR: $e'); + } + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/database/storage/doc_pengumuman.dart b/lib/dashboard/a_admin/database/storage/doc_pengumuman.dart new file mode 100644 index 0000000..450d5b5 --- /dev/null +++ b/lib/dashboard/a_admin/database/storage/doc_pengumuman.dart @@ -0,0 +1,28 @@ +import 'dart:typed_data'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DocPengumuman { + static final supabase = Supabase.instance.client; + + static Future uploadDoc(Uint8List bytes, String title, String ext) async { + try { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + final fileName = '${DateTime.now().millisecondsSinceEpoch}.$ext'; + final path = '$userId/$title/$fileName'; + + // Asumsikan bucket bernama 'pengumuman' + await supabase.storage.from('pengumuman').uploadBinary( + path, + bytes, + fileOptions: const FileOptions(upsert: true) + ); + + return supabase.storage.from('pengumuman').getPublicUrl(path); + } catch (e) { + print('Upload Doc Pengumuman Error: $e'); + return null; + } + } +} diff --git a/lib/dashboard/a_admin/database/storage/image_doc.dart b/lib/dashboard/a_admin/database/storage/image_doc.dart new file mode 100644 index 0000000..b6442e1 --- /dev/null +++ b/lib/dashboard/a_admin/database/storage/image_doc.dart @@ -0,0 +1,48 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class ImageDocStorage { + static final supabase = Supabase.instance.client; + + static Future uploadFile(Uint8List bytes, String fileName) async { + try { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + // path: userId/doc_image/fileName (di dalam bucket chat_file) + final path = '$userId/doc_image/${DateTime.now().millisecondsSinceEpoch}_$fileName'; + + await supabase.storage.from('chat_file').uploadBinary( + path, + bytes, + fileOptions: const FileOptions(upsert: true), + ); + + return supabase.storage.from('chat_file').getPublicUrl(path); + } catch (e) { + print('Upload Image/Doc Error: $e'); + return null; + } + } + + static Future uploadFileObject(File file, String fileName) async { + try { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + final path = '$userId/doc_image/${DateTime.now().millisecondsSinceEpoch}_$fileName'; + + await supabase.storage.from('chat_file').upload( + path, + file, + fileOptions: const FileOptions(upsert: true), + ); + + return supabase.storage.from('chat_file').getPublicUrl(path); + } catch (e) { + print('Upload File Error: $e'); + return null; + } + } +} diff --git a/lib/dashboard/a_admin/database/storage/image_profile.dart b/lib/dashboard/a_admin/database/storage/image_profile.dart new file mode 100644 index 0000000..4e18cf8 --- /dev/null +++ b/lib/dashboard/a_admin/database/storage/image_profile.dart @@ -0,0 +1,41 @@ +import 'dart:typed_data'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class ImageProfileStorage { + final _supabase = Supabase.instance.client; + final String _bucketName = 'image_profil'; + + // Mendapatkan Signed URL untuk gambar profil + Future getProfileImageUrl(String userId) async { + try { + final url = await _supabase.storage + .from(_bucketName) + .createSignedUrl('$userId/profile_admin', 60 * 60); // Expired dalam 1 jam + return url; + } catch (e) { + // Abaikan jika gambar belum ada atau RLS menolak akses + return null; + } + } + + // Mengupload gambar profil dan mengembalikan URL baru + Future uploadProfileImage(String userId, Uint8List bytes) async { + final path = '$userId/profile_admin'; + + await _supabase.storage + .from(_bucketName) + .uploadBinary(path, bytes, fileOptions: const FileOptions(upsert: true)); + + final newUrl = await _supabase.storage + .from(_bucketName) + .createSignedUrl(path, 60 * 60); + + return newUrl; + } + + // Menghapus gambar profil + Future deleteProfileImage(String userId) async { + final path = '$userId/profile_admin'; + await _supabase.storage.from(_bucketName).remove([path]); + } +} diff --git a/lib/dashboard/a_admin/function/f_alat/config_page.dart b/lib/dashboard/a_admin/function/f_alat/config_page.dart new file mode 100644 index 0000000..b7c88ed --- /dev/null +++ b/lib/dashboard/a_admin/function/f_alat/config_page.dart @@ -0,0 +1,277 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +class ConfigPage extends StatefulWidget { + const ConfigPage({super.key}); + + @override + State createState() => _ConfigPageState(); +} + +class _ConfigPageState extends State { + final ssidController = TextEditingController(); + final passwordController = TextEditingController(); + final urlController = TextEditingController(); + final espIPController = TextEditingController(); + + bool loading = false; + String espIP = "192.168.4.1"; + + @override + void initState() { + super.initState(); + loadESPIP(); + } + + @override + void dispose() { + ssidController.dispose(); + passwordController.dispose(); + urlController.dispose(); + espIPController.dispose(); + super.dispose(); + } + + Future loadESPIP() async { + final prefs = await SharedPreferences.getInstance(); + espIP = prefs.getString('esp_ip') ?? "192.168.4.1"; + espIPController.text = espIP; + if (mounted) setState(() {}); + } + + Future saveESPIP() async { + final prefs = await SharedPreferences.getInstance(); + espIP = espIPController.text.trim(); + await prefs.setString('esp_ip', espIP); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('ESP IP: $espIP')), + ); + } + if (mounted) setState(() {}); + } + + Future saveConfig() async { + setState(() => loading = true); + try { + final response = await http.post( + Uri.parse('http://$espIP/save'), + body: { + 'ssid': ssidController.text, + 'password': passwordController.text, + 'url': urlController.text, + }, + ).timeout(const Duration(seconds: 7)); + if (response.statusCode == 200) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('CONFIG SAVED')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + if (mounted) setState(() => loading = false); + } + + Future resetServer() async { + setState(() => loading = true); + try { + final response = await http.post(Uri.parse('http://$espIP/reset_server')).timeout(const Duration(seconds: 7)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response.body)), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + if (mounted) setState(() => loading = false); + } + + Widget field( + String label, + TextEditingController controller, { + bool obscureText = false, + String? hintText, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: hintText ?? "Masukkan $label", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + }) { + final bool isWhite = color == Colors.white; + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: isWhite ? const Color(0xFF64748B) : Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: isWhite ? const BorderSide(color: Color(0xFF94A3B8)) : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: isWhite ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("ESP32-S3 CONFIG"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("ESP IP", espIPController, hintText: "192.168.4.1"), + _actionButton( + label: "SIMPAN IP ESP", + color: Colors.white, + onPressed: saveESPIP, + ), + ], + ), + ), + + const SizedBox(height: 24), + + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("WiFi SSID", ssidController), + field("Password", passwordController, obscureText: true), + field("Server URL", urlController, hintText: "http://192.168.100.16:8000"), + + const SizedBox(height: 8), + + _actionButton( + label: "SAVE CONFIG", + color: const Color(0xFF2563EB), + onPressed: loading ? null : saveConfig, + loading: loading, + ), + + const SizedBox(height: 12), + + _actionButton( + label: "RESET SERVER CONNECTION", + color: const Color(0xFFDC2626), // Red color + onPressed: loading ? null : resetServer, + loading: loading, + ), + ], + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/function/f_alat/config_user.dart b/lib/dashboard/a_admin/function/f_alat/config_user.dart new file mode 100644 index 0000000..6865099 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_alat/config_user.dart @@ -0,0 +1,724 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/a_admin/function/f_murid/get_murid.dart'; + +class ConfigUser extends StatefulWidget { + final String? initialUid; + const ConfigUser({super.key, this.initialUid}); + + @override + State createState() => _ConfigUserState(); +} + +class _ConfigUserState extends State { + // Server URL terpisah — diset sendiri di halaman ini + final _serverUrlController = TextEditingController(); + + // Step 1 — Set Identitas (ARM) + final _uidController = TextEditingController(); + + // Step 3 — Detek / Absen + final _thresholdController = TextEditingController(text: '0.38'); + + bool _loadingArm = false; + bool _loadingEnroll = false; + bool _loadingDetect = false; + + String _statusArm = '-'; + String _statusEnroll = '-'; + String _statusDetect = '-'; + String _statusESP = '-'; + + String _serverUrl = 'http://192.168.100.16:8000'; + + /// Header wajib untuk melewati halaman peringatan ngrok. + /// Aman juga dipakai ke server non-ngrok (header tak dikenal diabaikan). + Map get _headers => { + 'ngrok-skip-browser-warning': 'true', + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + @override + void initState() { + super.initState(); + _loadPrefs(); + if (widget.initialUid != null) { + _uidController.text = widget.initialUid!; + } + } + + @override + void dispose() { + _serverUrlController.dispose(); + _uidController.dispose(); + _thresholdController.dispose(); + super.dispose(); + } + + Future _loadPrefs() async { + final prefs = await SharedPreferences.getInstance(); + // Hapus trailing slash agar tidak jadi double-slash saat digabung dengan path + final raw = prefs.getString('face_server_url') ?? 'http://192.168.100.16:8000'; + _serverUrl = raw.replaceAll(RegExp(r'/+$'), ''); + if (mounted) { + setState(() { + _serverUrlController.text = _serverUrl; + }); + } + } + + Future _saveServerUrl() async { + final prefs = await SharedPreferences.getInstance(); + // Hapus trailing slash agar tidak jadi double-slash saat digabung dengan path + _serverUrl = _serverUrlController.text.trim().replaceAll(RegExp(r'/+$'), ''); + _serverUrlController.text = _serverUrl; + await prefs.setString('face_server_url', _serverUrl); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Server URL disimpan: $_serverUrl'), + backgroundColor: Colors.green, + ), + ); + setState(() {}); + } + } + Future _arm() async { + final input = _uidController.text.trim(); + if (input.isEmpty) { + setState(() => _statusArm = 'UID / Nama tidak boleh kosong'); + return; + } + setState(() { + _loadingArm = true; + _statusArm = 'Mengirim...'; + }); + try { + // /arm/enroll menerima query param ?name=... (bukan body) + final uri = Uri.parse('$_serverUrl/arm/enroll') + .replace(queryParameters: {'name': input}); + final resp = await http.post(uri, headers: _headers) + .timeout(const Duration(seconds: 10)); + final body = _tryPrettyJson(resp.body); + setState(() => _statusArm = body); + } catch (e) { + setState(() => _statusArm = 'Error: $e'); + } finally { + setState(() => _loadingArm = false); + } + } + + Future _clearArm() async { + _uidController.clear(); + setState(() => _statusArm = '-'); + try { + // DELETE /arm/enroll untuk clear armed identity + final uri = Uri.parse('$_serverUrl/arm/enroll'); + final req = http.Request('DELETE', uri); + req.headers.addAll(_headers); + final streamedResp = await req.send().timeout(const Duration(seconds: 5)); + await streamedResp.stream.drain(); + } catch (_) {} + } + + Future _enroll() async { + setState(() { + _loadingEnroll = true; + _statusEnroll = 'Mengirim perintah Jepret & Daftar...'; + }); + try { + // /job/capture_enroll — pakai armed identity yang sudah di-SET sebelumnya + final resp = await http.post( + Uri.parse('$_serverUrl/job/capture_enroll'), + headers: _headers, + ).timeout(const Duration(seconds: 30)); + final body = _tryPrettyJson(resp.body); + setState(() => _statusEnroll = body); + _fetchESPStatus(); + } catch (e) { + setState(() => _statusEnroll = 'Error: $e'); + } finally { + setState(() => _loadingEnroll = false); + } + } + + Future _detect({bool quick = false}) async { + setState(() { + _loadingDetect = true; + _statusDetect = 'Mendeteksi wajah...'; + }); + try { + final threshold = _thresholdController.text.trim(); + + // /job/recog menerima query param ?threshold=...&uid=... + final params = {}; + if (threshold.isNotEmpty) params['threshold'] = threshold; + final uri = Uri.parse('$_serverUrl/job/recog') + .replace(queryParameters: params.isNotEmpty ? params : null); + + final resp = await http.post(uri, headers: _headers) + .timeout(const Duration(seconds: 30)); + final respBody = _tryPrettyJson(resp.body); + setState(() => _statusDetect = respBody); + _fetchESPStatus(); + } catch (e) { + setState(() => _statusDetect = 'Error: $e'); + } finally { + setState(() => _loadingDetect = false); + } + } + + Future _fetchESPStatus() async { + try { + // /job/last — status pekerjaan terakhir yang dikerjakan ESP32 + final resp = await http.get( + Uri.parse('$_serverUrl/job/last'), + headers: _headers, + ).timeout(const Duration(seconds: 8)); + setState(() => _statusESP = _tryPrettyJson(resp.body)); + } catch (e) { + setState(() => _statusESP = 'Tidak dapat mengambil status: $e'); + } + } + + String _tryPrettyJson(String raw) { + try { + final decoded = json.decode(raw); + return const JsonEncoder.withIndent(' ').convert(decoded); + } catch (_) { + return raw; + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Widget helpers + // ────────────────────────────────────────────────────────────────────────── + + + final _cleanupNameController = TextEditingController(); + final _cleanupUidController = TextEditingController(); + bool _loadingCleanup = false; + String _statusCleanup = '-'; + + Future _deleteByName() async { + final name = _cleanupNameController.text.trim(); + if (name.isEmpty) return; + setState(() { + _loadingCleanup = true; + _statusCleanup = 'Menghapus...'; + }); + try { + final uri = Uri.parse('$_serverUrl/person/delete').replace(queryParameters: {'name': name}); + final req = http.Request('DELETE', uri)..headers.addAll(_headers); + final resp = await req.send().timeout(const Duration(seconds: 10)); + final body = await resp.stream.bytesToString(); + setState(() => _statusCleanup = _tryPrettyJson(body)); + } catch (e) { + setState(() => _statusCleanup = 'Error: $e'); + } finally { + setState(() => _loadingCleanup = false); + } + } + + Future _deleteByUid() async { + final uid = _cleanupUidController.text.trim(); + if (uid.isEmpty) return; + setState(() { + _loadingCleanup = true; + _statusCleanup = 'Menghapus...'; + }); + try { + final uri = Uri.parse('$_serverUrl/person/delete').replace(queryParameters: {'uid': uid}); + final req = http.Request('DELETE', uri)..headers.addAll(_headers); + final resp = await req.send().timeout(const Duration(seconds: 10)); + final body = await resp.stream.bytesToString(); + setState(() => _statusCleanup = _tryPrettyJson(body)); + } catch (e) { + setState(() => _statusCleanup = 'Error: $e'); + } finally { + setState(() => _loadingCleanup = false); + } + } + + Future _cleanupUnused() async { + setState(() { + _loadingCleanup = true; + _statusCleanup = 'Menghapus data kosong...'; + }); + try { + final resp = await http.post( + Uri.parse('$_serverUrl/cleanup/unused'), + headers: _headers, + ).timeout(const Duration(seconds: 15)); + setState(() => _statusCleanup = _tryPrettyJson(resp.body)); + } catch (e) { + setState(() => _statusCleanup = 'Error: $e'); + } finally { + setState(() => _loadingCleanup = false); + } + } + + Widget _sectionTitle(String text) => Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + fontSize: 15, + color: Color(0xFF2563EB), + ), + ); + + Widget _subLabel(String text) => Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF64748B), + ), + ); + + Widget _fieldLabel(String text) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 13, + color: Color(0xFF1E293B), + ), + ), + ); + + InputDecoration _inputDeco(String hint) => InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter', fontSize: 13), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF2563EB), width: 1.5), + ), + ); + + Widget _actionBtn({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + double? width, + Color? textColor, + Color? borderColor, + }) { + final isOutlined = color == Colors.white || color == Colors.transparent; + return SizedBox( + width: width ?? double.infinity, + child: ElevatedButton( + onPressed: loading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: textColor ?? (isOutlined ? const Color(0xFF64748B) : Colors.white), + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: borderColor != null + ? BorderSide(color: borderColor) + : isOutlined + ? const BorderSide(color: Color(0xFFCBD5E1)) + : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: isOutlined ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w700, + color: textColor ?? (isOutlined ? const Color(0xFF64748B) : Colors.white), + ), + ), + ), + ); + } + + Widget _statusBox(String text) => Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 48), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: SelectableText( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF475569), + ), + ), + ); + + Widget _card({required Widget child}) => Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + // ignore: deprecated_member_use + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: child, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("ESP32 FACE CONTROL"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + tooltip: 'Refresh Status', + onPressed: _fetchESPStatus, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Server URL ────────────────────────────────────────────────── + _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Server URL'), + const SizedBox(height: 4), + _subLabel('URL server Python / ngrok yang sedang berjalan'), + const SizedBox(height: 12), + TextField( + controller: _serverUrlController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 13), + decoration: _inputDeco('https://xxxx.ngrok-free.app atau http://192.168.x.x:8000'), + ), + const SizedBox(height: 10), + _actionBtn( + label: 'SIMPAN URL', + color: Colors.white, + onPressed: _saveServerUrl, + borderColor: const Color(0xFF2563EB), + textColor: const Color(0xFF2563EB), + ), + ], + ), + ), + + const SizedBox(height: 14), + + // ── Step 1 + Step 2 + Step 3 ───────────────────────────────── + // On narrow screen: vertical; on wide: row (handled with LayoutBuilder) + LayoutBuilder(builder: (context, constraints) { + final isWide = constraints.maxWidth > 600; + if (isWide) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _step1Card()), + const SizedBox(width: 12), + Expanded(child: _step2Card()), + const SizedBox(width: 12), + Expanded(child: _step3Card()), + const SizedBox(width: 12), + Expanded(child: _cleanupCard()), + ], + ); + } + return Column( + children: [ + _step1Card(), + const SizedBox(height: 14), + _step2Card(), + const SizedBox(height: 14), + _step3Card(), + const SizedBox(height: 14), + _cleanupCard(), + ], + ); + }), + + const SizedBox(height: 14), + + // ── Status ESP32 ───────────────────────────────────────────── + _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _sectionTitle('Status terakhir dari ESP32'), + TextButton.icon( + onPressed: _fetchESPStatus, + icon: const Icon(Icons.sync, size: 16), + label: const Text( + 'Refresh', + style: TextStyle(fontFamily: 'Inter', fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 4), + _subLabel('ESP32 akan kirim hasil ke server setelah selesai.'), + const SizedBox(height: 10), + _statusBox(_statusESP), + ], + ), + ), + ], + ), + ), + ); + } + + // ── Panel Step 1 ───────────────────────────────────────────────────────── + Widget _step1Card() => _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Step 1 — Set Identitas (ARM)'), + const SizedBox(height: 4), + _subLabel( + 'Isi UID RFID (atau nama). Ini belum jepret.\nHanya "ngunci" identitas dulu.', + ), + const SizedBox(height: 14), + _fieldLabel('UID / Name'), + InkWell( + onTap: () async { + final selected = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MuridPage(isSelectionMode: true), + ), + ); + if (selected != null && selected is Map) { + setState(() { + _uidController.text = selected['nama']?.toString() ?? ''; + }); + } + }, + child: IgnorePointer( + child: TextField( + controller: _uidController, + readOnly: true, + style: const TextStyle(fontFamily: 'Inter', fontSize: 13), + decoration: _inputDeco('Pilih Murid dari Daftar...').copyWith( + suffixIcon: const Icon(Icons.arrow_drop_down), + ), + ), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _actionBtn( + label: 'SET', + color: const Color(0xFF2563EB), + onPressed: _arm, + loading: _loadingArm, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _actionBtn( + label: 'CLEAR', + color: Colors.white, + onPressed: _clearArm, + borderColor: const Color(0xFFCBD5E1), + ), + ), + ], + ), + const SizedBox(height: 10), + _statusBox(_statusArm), + ], + ), + ); + + // ── Panel Step 2 ───────────────────────────────────────────────────────── + Widget _step2Card() => _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Step 2 — Jepret & Daftar Wajah'), + const SizedBox(height: 4), + _subLabel( + 'Setelah UID/name di Step 1, pencet ini\nuntuk jepret dan enroll.', + ), + const SizedBox(height: 14), + _actionBtn( + label: 'JEPRET & DAFTAR', + color: const Color(0xFF059669), + onPressed: _enroll, + loading: _loadingEnroll, + ), + const SizedBox(height: 10), + _statusBox(_statusEnroll), + ], + ), + ); + + // ── Panel Step 3 ───────────────────────────────────────────────────────── + + // ── Panel Cleanup ──────────────────────────────────────────────────────── + Widget _cleanupCard() => _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Manajemen Data & Cleanup'), + const SizedBox(height: 4), + _subLabel('Hapus pengguna spesifik atau bersihkan baris data kosong tanpa wajah & kartu.'), + const SizedBox(height: 14), + + _fieldLabel('Nama Pengguna'), + TextField( + controller: _cleanupNameController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 13), + decoration: _inputDeco('Andi'), + ), + const SizedBox(height: 10), + _actionBtn( + label: 'HAPUS BY NAMA', + color: const Color(0xFFDC2626), + onPressed: _deleteByName, + loading: _loadingCleanup, + ), + + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Divider(color: Color(0xFFE2E8F0)), + ), + + _fieldLabel('UID RFID'), + TextField( + controller: _cleanupUidController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 13), + decoration: _inputDeco('04 AB:12 CD'), + ), + const SizedBox(height: 10), + _actionBtn( + label: 'HAPUS BY RFID', + color: const Color(0xFFDC2626), + onPressed: _deleteByUid, + loading: _loadingCleanup, + ), + + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Divider(color: Color(0xFFE2E8F0)), + ), + + _actionBtn( + label: 'BERSIHKAN DATA KOSONG', + color: const Color(0xFFF59E0B), + onPressed: _cleanupUnused, + loading: _loadingCleanup, + ), + + const SizedBox(height: 10), + _statusBox(_statusCleanup), + ], + ), + ); + + Widget _step3Card() => _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Detek / Absen (Recognize)'), + const SizedBox(height: 4), + _subLabel( + 'Pencet untuk mulai deteksi.\nESP32 ambil foto dan kirim ke server.', + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _actionBtn( + label: 'DETEK SEKARANG', + color: const Color(0xFF7C3AED), + onPressed: () => _detect(quick: true), + loading: _loadingDetect, + ), + ), + const SizedBox(width: 8), + _subLabel('1 klik,\npakai\nthreshold\ndefault'), + ], + ), + const SizedBox(height: 14), + _fieldLabel('Threshold'), + TextField( + controller: _thresholdController, + keyboardType: TextInputType.number, + style: const TextStyle(fontFamily: 'Inter', fontSize: 13), + decoration: _inputDeco('0.38'), + ), + const SizedBox(height: 10), + _actionBtn( + label: 'DETEK', + color: const Color(0xFF2563EB), + onPressed: _detect, + loading: _loadingDetect, + ), + const SizedBox(height: 10), + _statusBox(_statusDetect), + ], + ), + ); +} diff --git a/lib/dashboard/a_admin/function/f_guru/detail_guru.dart b/lib/dashboard/a_admin/function/f_guru/detail_guru.dart new file mode 100644 index 0000000..bacc71a --- /dev/null +++ b/lib/dashboard/a_admin/function/f_guru/detail_guru.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_guru.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/a_admin/function/f_guru/update_guru.dart'; + +class DetailGuruPage extends StatefulWidget { + final Map data; + + const DetailGuruPage({ + super.key, + required this.data, + }); + + @override + State createState() => _DetailGuruPageState(); +} + +class _DetailGuruPageState extends State { + late Map currentData; + + @override + void initState() { + super.initState(); + currentData = Map.from(widget.data); + } + + Future deleteData() async { + try { + await GuruService.deleteGuru(currentData['id_tabel']); + if (!mounted) return; + Navigator.pop(context, true); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))), + ); + } + } + + String get _nama => currentData['name']?.toString().isEmpty ?? true ? "Belum diisi" : currentData['name']; + String get _nik => currentData['nik']?.toString().isEmpty ?? true ? "Belum diisi" : currentData['nik'].toString(); + bool get _isWali => currentData['wali'] == true; + String get _bidang => currentData['bidang']?.toString() ?? ""; + + String get _badge { + final kelas = currentData['class_name']?['name_class']?.toString() ?? ""; + if (_isWali) { + return kelas.isEmpty ? "Wali Kelas" : kelas; + } + return _bidang.isEmpty ? "Guru" : _bidang; + } + + IconData _iconForLabel(String label) { + switch (label) { + case "NIK": + return Icons.badge_outlined; + case "Nama": + return Icons.person_outline; + case "Bidang": + return Icons.menu_book_outlined; + default: + return Icons.info_outline; + } + } + + Widget _infoCard({ + required IconData icon, + required String title, + required String value, + bool isEmpty = false, + }) { + return CustomCard( + title: title, + subtitle: value, + icon: icon, + iconPosition: IconPosition.left, + iconColor: const Color(0xFF2563EB), + iconSize: 24, + iconContainerSize: 46, + iconDecoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(9), + ), + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + borderRadius: 0, + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + boxShadow: const [], + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF8FA0BA), + ), + subtitleStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + fontStyle: isEmpty ? FontStyle.italic : FontStyle.normal, + color: isEmpty ? const Color(0xFFC8D2E0) : const Color(0xFF172033), + ), + showIcon: true, + ); + } + + Widget _sectionCard(List children) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8EEF6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) + const Divider( + height: 1, + thickness: 1, + color: Color(0xFFF0F4FA), + indent: 82, + endIndent: 20, + ), + ], + ], + ), + ), + ); + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 12), + child: Text( + title, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w800, + color: Color(0xFF64748B), + ), + ), + ); + } + + Widget _profileHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE8EEF6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _nama, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w800, + color: Color(0xFF172033), + ), + ), + const SizedBox(height: 16), + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 16, + runSpacing: 10, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.badge_outlined, + size: 18, + color: Color(0xFF8FA0BA), + ), + const SizedBox(width: 6), + Text( + "NIK: $_nik", + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.school_outlined, + size: 18, + color: Color(0xFF2563EB), + ), + const SizedBox(width: 6), + Text( + _badge, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF2563EB), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + }) { + final bool isWhite = color == Colors.white; + return Expanded( + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: isWhite ? const Color(0xFF64748B) : Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: isWhite ? const BorderSide(color: Color(0xFF94A3B8)) : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: isWhite ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final kelas = currentData['class_name']?['name_class']?.toString() ?? ""; + + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Detail Guru"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), + children: [ + _profileHeader(), + const SizedBox(height: 20), + _sectionTitle("Identitas"), + _sectionCard([ + _infoCard( + icon: _iconForLabel("Bidang"), + title: "Bidang", + value: _bidang.isEmpty ? "Belum diisi" : _bidang, + isEmpty: _bidang.isEmpty, + ), + _infoCard( + icon: Icons.verified_user_outlined, + title: "Wali Kelas", + value: _isWali ? "Ya" : "Tidak", + ), + if (_isWali || currentData['id_class'] != null) + _infoCard( + icon: Icons.school_outlined, + title: "Kelas", + value: kelas.isEmpty ? "Belum diisi" : kelas, + isEmpty: kelas.isEmpty, + ), + ]), + const SizedBox(height: 24), + Row( + children: [ + _actionButton( + label: "Edit", + color: const Color(0xFF2563EB), + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => UpdateGuruPage(data: currentData), + ), + ); + if (result != null && mounted) { + setState(() { + currentData = result; + }); + } + }, + ), + const SizedBox(width: 10), + _actionButton( + label: "Delete", + color: Colors.red, + onPressed: deleteData, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_guru/false_delete1.dart b/lib/dashboard/a_admin/function/f_guru/false_delete1.dart new file mode 100644 index 0000000..bbfdc4f --- /dev/null +++ b/lib/dashboard/a_admin/function/f_guru/false_delete1.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_guru.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; + +class FalseDeleteGuruPage extends StatefulWidget { + const FalseDeleteGuruPage({super.key}); + + @override + State createState() => _FalseDeleteGuruPageState(); +} + +class _FalseDeleteGuruPageState extends State { + List> deletedGuruList = []; + bool isLoading = true; + String? errorMessage; + + @override + void initState() { + super.initState(); + loadData(); + } + + Future loadData() async { + try { + final data = await GuruService.getDeletedGuru(); + if (mounted) { + setState(() { + deletedGuruList = data; + isLoading = false; + errorMessage = null; + }); + } + } catch (e) { + if (mounted) { + setState(() { + isLoading = false; + errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } + } + + Future restoreGuru(String idTabel, String name) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Pulihkan Guru"), + content: Text("Apakah Anda yakin ingin memulihkan data guru $name?"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text("Batal"), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF2563EB)), + child: const Text("Pulihkan", style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirm != true) return; + + setState(() => isLoading = true); + try { + await GuruService.restoreGuru(idTabel); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Data guru $name berhasil dipulihkan")), + ); + } + loadData(); + } catch (e) { + if (mounted) { + setState(() => isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Gagal memulihkan: ${e.toString().replaceAll('Exception: ', '')}")), + ); + } + } + } + + Future deletePermanentlyGuru(String idTabel, String name) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Hapus Permanen"), + content: Text("Apakah Anda yakin ingin menghapus permanen data guru $name? Tindakan ini tidak dapat dibatalkan."), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text("Batal"), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text("Hapus", style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirm != true) return; + + setState(() => isLoading = true); + try { + await GuruService.deleteGuruPermanently(idTabel); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Data guru $name berhasil dihapus permanen")), + ); + } + loadData(); + } catch (e) { + if (mounted) { + setState(() => isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Gagal menghapus: ${e.toString().replaceAll('Exception: ', '')}")), + ); + } + } + } + + String safe(dynamic value) { + if (value == null || value.toString().isEmpty) return "N/A"; + return value.toString(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Tempat Sampah Guru"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: RefreshIndicator( + onRefresh: loadData, + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : errorMessage != null + ? ListView( + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.3), + Center( + child: Text( + "Terjadi Kesalahan:\n$errorMessage", + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ) + : deletedGuruList.isEmpty + ? ListView( + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.35), + const Center( + child: Text( + "Tidak ada data guru yang dihapus", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Colors.grey, + ), + ), + ), + ], + ) + : ListView.builder( + itemCount: deletedGuruList.length, + itemBuilder: (context, index) { + final g = deletedGuruList[index]; + final nama = safe(g['name']); + final nik = safe(g['nik']); + final bidang = safe(g['bidang']); + + return Column( + children: [ + if (index == 0) const SizedBox(height: 12), + CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + 'NIK: $nik', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Bidang: $bidang', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + customIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => restoreGuru(g['id_tabel'], nama), + borderRadius: BorderRadius.circular(20), + child: const Padding( + padding: EdgeInsets.all(6.0), + child: Icon(Icons.restore, color: Colors.green, size: 22), + ), + ), + const SizedBox(width: 4), + InkWell( + onTap: () => deletePermanentlyGuru(g['id_tabel'], nama), + borderRadius: BorderRadius.circular(20), + child: const Padding( + padding: EdgeInsets.all(6.0), + child: Icon(Icons.delete_forever, color: Colors.red, size: 22), + ), + ), + ], + ), + showIcon: true, + iconContainerSize: 80, + iconPadding: EdgeInsets.zero, + iconDecoration: const BoxDecoration(), + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_guru/form_guru.dart b/lib/dashboard/a_admin/function/f_guru/form_guru.dart new file mode 100644 index 0000000..3c2de95 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_guru/form_guru.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_guru.dart'; +import 'package:apk/dashboard/a_admin/function/f_kelas/form_kelas..dart'; + +class FormGuruPage extends StatefulWidget { + const FormGuruPage({super.key}); + + @override + State createState() => _FormGuruPageState(); +} + +class _FormGuruPageState extends State { + final nikController = TextEditingController(); + final namaController = TextEditingController(); + final bidangController = TextEditingController(); + + List> kelasList = []; + String? selectedClass; + bool isWali = false; + + bool isLoading = false; + + @override + void initState() { + super.initState(); + loadKelas(); + } + + Future loadKelas() async { + final data = await GuruService.getKelas(); + setState(() { + kelasList = data; + }); + } + + Future simpan() async { + if (nikController.text.isEmpty || namaController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("NIK dan Nama wajib diisi!")), + ); + return; + } + + if (isWali && selectedClass == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Kelas wajib dipilih jika menjadi Wali Kelas!")), + ); + return; + } + + setState(() => isLoading = true); + + try { + await GuruService.tambahGuru( + nik: nikController.text, + name: namaController.text, + bidang: bidangController.text.isEmpty ? null : bidangController.text, + idClass: selectedClass, + wali: isWali, + ); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Data guru berhasil disimpan")), + ); + + // reset form + nikController.clear(); + namaController.clear(); + bidangController.clear(); + + setState(() { + selectedClass = null; + isWali = false; + }); + + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))), + ); + } + + if (mounted) setState(() => isLoading = false); + } + + Widget _buildLabel(String text) { + return Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ); + } + + InputDecoration _buildDecoration(String hintText) { + return InputDecoration( + hintText: hintText, + hintStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Form Guru"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 10), + child: ListView( + children: [ + // NIK + _buildLabel("NIK *"), + const SizedBox(height: 6), + TextField( + controller: nikController, + keyboardType: TextInputType.number, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Masukkan NIK Guru"), + ), + const SizedBox(height: 16), + + // Nama + _buildLabel("Nama Lengkap *"), + const SizedBox(height: 6), + TextField( + controller: namaController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Nama Lengkap Guru"), + ), + const SizedBox(height: 16), + + // Bidang + _buildLabel("Bidang Mata Pelajaran"), + const SizedBox(height: 6), + TextField( + controller: bidangController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Contoh: Matematika"), + ), + const SizedBox(height: 16), + + // Pilih sebagai + _buildLabel("Pilih sebagai"), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Card( + color: const Color(0xFFF1F5F9), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + setState(() { + isWali = true; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Wali Kelas", style: TextStyle(fontFamily: 'Inter', fontSize: 14, fontWeight: FontWeight.w600)), + Checkbox( + value: isWali, + activeColor: const Color(0xFF2563EB), + onChanged: (val) { + setState(() { + isWali = true; + }); + }, + ), + ], + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Card( + color: const Color(0xFFF1F5F9), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + setState(() { + isWali = false; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Guru", style: TextStyle(fontFamily: 'Inter', fontSize: 14, fontWeight: FontWeight.w600)), + Checkbox( + value: !isWali, + activeColor: const Color(0xFF2563EB), + onChanged: (val) { + setState(() { + isWali = false; + }); + }, + ), + ], + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Kelas + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildLabel("Pilih Kelas (Wajib jika Wali Kelas)"), + TextButton( + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FormKelasPage()), + ); + loadKelas(); + }, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + "Buat Kelas", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF2563EB), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + DropdownButtonFormField( + value: selectedClass, + hint: const Text( + "Pilih Kelas", + style: TextStyle(fontFamily: 'Inter', fontSize: 14, color: Color(0xFFC4C4C4)), + ), + decoration: _buildDecoration("").copyWith(hintText: null), + items: kelasList.map((kelas) { + return DropdownMenuItem( + value: kelas['id_tabel'], + child: Text( + kelas['name_class'], + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + ), + ); + }).toList(), + onChanged: (value) { + setState(() { + selectedClass = value; + }); + }, + ), + const SizedBox(height: 24), + + ElevatedButton( + onPressed: isLoading ? null : simpan, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + elevation: 0, + ), + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Text( + "Simpan", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_guru/get_guru.dart b/lib/dashboard/a_admin/function/f_guru/get_guru.dart new file mode 100644 index 0000000..fd0e189 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_guru/get_guru.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_guru.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/a_admin/function/f_guru/detail_guru.dart'; + +import 'package:apk/dashboard/a_admin/function/f_guru/false_delete1.dart'; + +class GuruPage extends StatefulWidget { + const GuruPage({super.key}); + + @override + State createState() => _GuruPageState(); +} + +class _GuruPageState extends State { + List> guruList = []; + bool isLoading = true; + String? errorMessage; + + @override + void initState() { + super.initState(); + loadData(); + } + + Future loadData() async { + try { + final data = await GuruService.getGuru(); + setState(() { + guruList = data; + isLoading = false; + errorMessage = null; + }); + } catch (e) { + setState(() { + isLoading = false; + errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } + + String safe(dynamic value) { + if (value == null || value.toString().isEmpty) return "N/A"; + return value.toString(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Manajemen Guru"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + actions: [ + IconButton( + icon: const Icon(Icons.restore_from_trash, color: Colors.white), + tooltip: 'Tempat Sampah', + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const FalseDeleteGuruPage()), + ); + loadData(); + }, + ), + ], + ), + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : errorMessage != null + ? Center( + child: Padding( + //padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 10), + child: Text( + "Terjadi Kesalahan:\n$errorMessage", + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + ) + : guruList.isEmpty + ? const Center( + child: Text( + "Belum ada data guru", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Colors.grey, + ), + ), + ) + : Column( + children: [ + const SizedBox(height: 12), + Expanded( + child: ListView.builder( + itemCount: guruList.length, + itemBuilder: (context, index) { + final g = guruList[index]; + final nama = safe(g['name']); + final nik = safe(g['nik']); + final isWali = g['wali'] == true; + final kelas = g['class_name']?['name_class'] ?? + 'Belum ada kelas'; + final bidang = safe(g['bidang']); + final detail = isWali + ? 'Wali Kelas: $kelas' + : 'Bidang: $bidang'; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 0, + vertical: 0, + ), + child: CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + 'NIK: $nik', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + detail, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + DetailGuruPage(data: g), + ), + ); + loadData(); + }, + ), + ), + const SizedBox(height: 12), + ], + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_guru/update_guru.dart b/lib/dashboard/a_admin/function/f_guru/update_guru.dart new file mode 100644 index 0000000..e2eee54 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_guru/update_guru.dart @@ -0,0 +1,413 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_guru.dart'; + +class UpdateGuruPage extends StatefulWidget { + final Map data; + + const UpdateGuruPage({ + super.key, + required this.data, + }); + + @override + State createState() => _UpdateGuruPageState(); +} + +class _UpdateGuruPageState extends State { + bool isSaving = false; + + late TextEditingController nikC; + late TextEditingController nameC; + late TextEditingController bidangC; + + List> kelasList = []; + String? selectedKelas; + late bool isWali; + + @override + void initState() { + super.initState(); + + nikC = TextEditingController(text: widget.data['nik']?.toString() ?? ""); + nameC = TextEditingController(text: widget.data['name'] ?? ""); + bidangC = TextEditingController(text: widget.data['bidang'] ?? ""); + + selectedKelas = widget.data['id_class']; + isWali = widget.data['wali'] == true; + + loadKelas(); + } + + Future loadKelas() async { + final data = await GuruService.getKelas(); + if (mounted) { + setState(() => kelasList = data); + } + } + + Future updateData() async { + if (nikC.text.isEmpty || nameC.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("NIK dan Nama wajib diisi!")), + ); + return; + } + + if (isWali && selectedKelas == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Kelas wajib dipilih jika menjadi Wali Kelas!"), + ), + ); + return; + } + + setState(() => isSaving = true); + + try { + await GuruService.updateGuru( + idTabel: widget.data['id_tabel'], + nik: nikC.text, + name: nameC.text, + bidang: bidangC.text.isEmpty ? null : bidangC.text, + idClass: selectedKelas, + wali: isWali, + ); + + final selectedKelasData = kelasList.where( + (kelas) => kelas['id_tabel'] == selectedKelas, + ); + + if (mounted) { + final Map updatedData = Map.from(widget.data); + updatedData['nik'] = nikC.text; + updatedData['name'] = nameC.text; + updatedData['bidang'] = bidangC.text; + updatedData['id_class'] = selectedKelas; + updatedData['wali'] = isWali; + updatedData['class_name'] = selectedKelasData.isEmpty + ? null + : { + 'id_tabel': selectedKelasData.first['id_tabel'], + 'name_class': selectedKelasData.first['name_class'], + }; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Data guru berhasil diperbarui')), + ); + Navigator.pop(context, updatedData); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Gagal menyimpan: ${e.toString().replaceAll('Exception: ', '')}", + ), + ), + ); + } + } finally { + if (mounted) { + setState(() => isSaving = false); + } + } + } + + Widget field( + String label, + TextEditingController c, { + TextInputType? keyboardType, + String hint = "", + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + TextField( + controller: c, + keyboardType: keyboardType, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget kelasField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Kelas", + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + DropdownButtonFormField( + value: selectedKelas, + decoration: InputDecoration( + hintText: "Pilih Kelas", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + items: kelasList.map((e) { + return DropdownMenuItem( + value: e['id_tabel'], + child: Text(e['name_class'], style: const TextStyle(fontFamily: 'Inter')), + ); + }).toList(), + onChanged: (v) => setState(() => selectedKelas = v), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget waliField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Wali Kelas", + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Jadikan Wali Kelas", + style: TextStyle(fontFamily: 'Inter', fontSize: 14, color: Color(0xFF1E293B)), + ), + Switch( + value: isWali, + activeColor: const Color(0xFF2563EB), + onChanged: (val) => setState(() => isWali = val), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + }) { + final bool isWhite = color == Colors.white; + return Expanded( + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: isWhite ? const Color(0xFF64748B) : Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: isWhite ? const BorderSide(color: Color(0xFF94A3B8)) : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: isWhite ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + void dispose() { + nikC.dispose(); + nameC.dispose(); + bidangC.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Edit Data Guru"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: Container( + color: Colors.white, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Expanded( + child: ListView( + children: [ + Row( + children: const [ + Text("👩‍🏫", style: TextStyle(fontSize: 14)), + SizedBox(width: 8), + Text( + "Data Guru", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("NIK *", nikC, keyboardType: TextInputType.number, hint: "Contoh: 33111"), + field("Nama *", nameC, hint: "Contoh: Budi Santoso"), + field("Bidang", bidangC, hint: "Contoh: Matematika"), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: const [ + Text("🏫", style: TextStyle(fontSize: 14)), + SizedBox(width: 8), + Text( + "Wali Kelas & Kelas", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + waliField(), + if (isWali || selectedKelas != null) kelasField(), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + _actionButton( + label: "Simpan", + color: const Color(0xFF2563EB), + onPressed: isSaving ? null : updateData, + loading: isSaving, + ), + const SizedBox(width: 10), + _actionButton( + label: "Batal", + color: Colors.white, + onPressed: isSaving ? null : () => Navigator.pop(context), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_kelas/content_kelas.dart b/lib/dashboard/a_admin/function/f_kelas/content_kelas.dart new file mode 100644 index 0000000..f643e1b --- /dev/null +++ b/lib/dashboard/a_admin/function/f_kelas/content_kelas.dart @@ -0,0 +1,594 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_kelas.dart'; +import 'package:apk/dashboard/a_admin/database/db_absen.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/a_admin/function/f_kelas/update_kelas.dart'; +import 'package:apk/dashboard/a_admin/function/f_murid/detai_murid.dart'; +import 'package:apk/dashboard/a_admin/function/f_guru/detail_guru.dart'; + +class ContentKelas extends StatefulWidget { + final Map classData; + + const ContentKelas({super.key, required this.classData}); + + @override + State createState() => _ContentKelasState(); +} + +class _ContentKelasState extends State { + List> muridList = []; + List> guruList = []; + bool isLoading = true; + + @override + void initState() { + super.initState(); + loadData(); + } + + Future loadData() async { + try { + final String classId = widget.classData['id_tabel']; + final resMurid = await ClassService.getMuridByClass(classId); + final resGuru = await ClassService.getGuruByClass(classId); + + setState(() { + muridList = resMurid ?? []; + guruList = resGuru ?? []; + isLoading = false; + }); + } catch (e) { + print(e); + setState(() { + isLoading = false; + }); + } + } + + void _showPromoteDialog() async { + if (muridList.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Tidak ada murid di kelas ini untuk dinaikkan tingkat.")), + ); + return; + } + + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + "Peringatan Naik Tingkat", + style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 18, color: Colors.red), + ), + content: const Text( + "Proses naik tingkat kelas akan memindahkan seluruh murid ke kelas baru dan me-reset seluruh data absensi serta riwayat chat private mereka secara permanen.\n\nApakah Anda yakin ingin melanjutkan?", + style: TextStyle(fontFamily: 'Inter', fontSize: 13, color: Color(0xFF1E293B)), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text("Batal", style: TextStyle(fontFamily: 'Inter', color: Color(0xFF64748B))), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text( + "Lanjut", + style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ], + ), + ); + + if (confirm != true) return; + + setState(() => isLoading = true); + try { + final classes = await ClassService.getClasses(); + final currentClassId = widget.classData['id_tabel']; + final otherClasses = classes.where((c) => c['id_tabel'] != currentClassId).toList(); + + setState(() => isLoading = false); + + if (otherClasses.isEmpty) { + if (!mounted) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text("Kelas Tujuan Tidak Ditemukan", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold)), + content: const Text("Silakan buat kelas tujuan baru terlebih dahulu untuk menaikkan tingkat kelas ini."), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("OK", style: TextStyle(fontFamily: 'Inter', color: Color(0xFF2563EB))), + ) + ], + ), + ); + return; + } + + String? selectedTargetClass; + if (!mounted) return; + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + "Naik Tingkat Kelas", + style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 18), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Pilih kelas tujuan untuk memindahkan seluruh murid di kelas ini:", + style: TextStyle(fontFamily: 'Inter', fontSize: 13, color: Color(0xFF64748B)), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + isExpanded: true, + value: selectedTargetClass, + hint: const Text( + "Pilih Kelas Tujuan", + style: TextStyle(fontFamily: 'Inter', fontSize: 13, color: Color(0xFFC4C4C4)), + ), + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + items: otherClasses.map((c) { + return DropdownMenuItem( + value: c['id_tabel'].toString(), + child: Text( + c['name_class'] ?? 'Tanpa Nama', + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + ), + ); + }).toList(), + onChanged: (val) { + setDialogState(() { + selectedTargetClass = val; + }); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Batal", style: TextStyle(fontFamily: 'Inter', color: Color(0xFF64748B))), + ), + ElevatedButton( + onPressed: selectedTargetClass == null + ? null + : () { + Navigator.pop(context); + final targetClassObj = otherClasses.firstWhere((c) => c['id_tabel'].toString() == selectedTargetClass); + final targetStudentCount = (targetClassObj['murid'] as List? ?? []).length; + _confirmPromotion( + selectedTargetClass!, + targetClassObj['name_class'] ?? '', + targetStudentCount, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text( + "Lanjut", + style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ], + ); + }, + ); + }, + ); + } catch (e) { + setState(() => isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Gagal memuat kelas tujuan: $e")), + ); + } + } + + void _confirmPromotion(String targetClassId, String targetClassName, int targetStudentCount) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + "Konfirmasi Naik Tingkat", + style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 18, color: Colors.red), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Apakah Anda yakin ingin memindahkan seluruh murid ke kelas '$targetClassName'?\n\nTindakan ini akan me-reset seluruh data absen dan riwayat chat private murid-murid tersebut secara permanen. Data murid itu sendiri tidak akan dihapus.", + style: const TextStyle(fontFamily: 'Inter', fontSize: 13, color: Color(0xFF1E293B)), + ), + if (targetStudentCount > 0) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFFCA5A5)), + ), + child: Row( + children: [ + const Icon(Icons.warning, color: Colors.red, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + "Peringatan: Kelas tujuan '$targetClassName' saat ini masih memiliki $targetStudentCount murid. Murid baru akan digabungkan dengan murid yang sudah ada di kelas tersebut.", + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF991B1B), + ), + ), + ), + ], + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Batal", style: TextStyle(fontFamily: 'Inter', color: Color(0xFF64748B))), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); + setState(() => isLoading = true); + try { + final studentIds = muridList.map((m) => m['id_tabel'].toString()).toList(); + debugPrint("Promoting class for student IDs: $studentIds"); + await AbsenService.promoteClass( + sourceClassId: widget.classData['id_tabel'].toString(), + targetClassId: targetClassId, + studentIds: studentIds, + ); + debugPrint("AbsenService.promoteClass completed successfully"); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Berhasil menaikkan tingkat murid ke kelas '$targetClassName'"), + backgroundColor: Colors.green, + ), + ); + debugPrint("Refreshing murid/guru lists..."); + await loadData(); + debugPrint("loadData completed"); + } catch (e) { + debugPrint("Exception during class promotion: $e"); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Gagal menaikkan tingkat kelas: $e")), + ); + } finally { + if (mounted) { + setState(() => isLoading = false); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text( + "Ya, Proses", + style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final nameClass = widget.classData['name_class'] ?? 'Tanpa Nama'; + + String guruName = '-'; + if (widget.classData['guru'] is List) { + final List gurus = widget.classData['guru'] as List; + final waliGuru = gurus.firstWhere((g) => g['wali'] == true, orElse: () => null); + if (waliGuru != null) { + guruName = waliGuru['name']?.toString() ?? '-'; + } + } + + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: Text(nameClass), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + actions: [ + Row( + children: [ + const Icon(Icons.calendar_today, color: Colors.white, size: 16), + const SizedBox(width: 6), + Text( + widget.classData['tahun'] ?? '-', + style: const TextStyle(fontSize: 14, color: Colors.white), + ), + IconButton( + icon: const Icon(Icons.edit, color: Colors.white, size: 20), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => UpdateKelasPage(classData: widget.classData), + ), + ).then((result) { + if (result != null && result is Map) { + setState(() { + widget.classData['name_class'] = result['name_class']; + widget.classData['id_class'] = result['id_class']; + widget.classData['tahun'] = result['tahun']; + }); + } + loadData(); // Refresh data after returning + }); + }, + ), + ], + ), + ], + ), + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.only(bottom: 20), + children: [ + // --- BAGIAN GURU --- + if (guruList.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20.0, vertical: 10), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "Daftar Guru", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + ), + ), + ...guruList.map((g) { + final isWali = g['wali'] == true; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: CustomCard( + title: g['name'] ?? 'Tanpa Nama', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + isWali ? "Wali Kelas" : "Guru Mapel", + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'NIK: ${g['nik'] ?? '-'}', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DetailGuruPage(data: g), + ), + ); + loadData(); + }, + ), + ); + }), + ], + + // --- BAGIAN MURID --- + const Padding( + padding: EdgeInsets.only(left: 20.0, right: 20.0, top: 20, bottom: 10), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "Daftar Murid", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + ), + ), + if (muridList.isEmpty) + const Padding( + padding: EdgeInsets.all(20), + child: Center( + child: Text( + "Belum ada murid di kelas ini", + style: TextStyle(color: Colors.grey), + ), + ), + ) + else + ...muridList.map((m) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: CustomCard( + title: m['nama'] ?? 'Tanpa Nama', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + 'NIS: ${m['nis'] ?? '-'}', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Siswa', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DetailMuridPage(data: m), + ), + ); + loadData(); + }, + ), + ); + }), + if (muridList.isNotEmpty) ...[ + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ElevatedButton.icon( + onPressed: _showPromoteDialog, + icon: const Icon(Icons.arrow_upward, color: Colors.white, size: 18), + label: const Text( + "Naik Tingkat Kelas", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + minimumSize: const Size(double.infinity, 50), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_kelas/form_kelas..dart b/lib/dashboard/a_admin/function/f_kelas/form_kelas..dart new file mode 100644 index 0000000..5d26ce1 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_kelas/form_kelas..dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_kelas.dart'; + +class FormKelasPage extends StatefulWidget { + const FormKelasPage({super.key}); + + @override + State createState() => _FormKelasPageState(); +} + +class _FormKelasPageState extends State { + final nameClassController = TextEditingController(); + final tahunAwalController = TextEditingController(); + final tahunAkhirController = TextEditingController(); + + int? selectedTingkat; + + bool isLoading = false; + + @override + void initState() { + super.initState(); + loadData(); + } + + Future loadData() async { + // Intentionally left empty if we don't load guru/murid here anymore. + // If needed in the future, load other class-related data here. + } + + Future simpan() async { + if (nameClassController.text.isEmpty || selectedTingkat == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Nama Kelas dan Tingkat wajib diisi!")), + ); + return; + } + + setState(() => isLoading = true); + + try { + String tahunStr = ""; + if (tahunAwalController.text.isNotEmpty && tahunAkhirController.text.isNotEmpty) { + tahunStr = "${tahunAwalController.text} / ${tahunAkhirController.text}"; + } + + await ClassService.tambahKelas( + idClass: selectedTingkat!, + nameClass: nameClassController.text, + idGuru: null, + idMurid: null, + tahun: tahunStr.isEmpty ? null : tahunStr, + ); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Kelas berhasil disimpan")), + ); + + // reset form + nameClassController.clear(); + tahunAwalController.clear(); + tahunAkhirController.clear(); + + setState(() { + selectedTingkat = null; + }); + + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error: $e")), + ); + } + + if (mounted) setState(() => isLoading = false); + } + + Widget _buildLabel(String text) { + return Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ); + } + + InputDecoration _buildDecoration(String hintText) { + return InputDecoration( + hintText: hintText, + hintStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Form Kelas"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 10), + child: ListView( + children: [ + // Nama Kelas + _buildLabel("Nama Kelas *"), + const SizedBox(height: 6), + TextField( + controller: nameClassController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Contoh: Kelas 1A"), + ), + const SizedBox(height: 16), + + // Tingkat + _buildLabel("Tingkat Kelas *"), + const SizedBox(height: 6), + DropdownButtonFormField( + value: selectedTingkat, + hint: const Text( + "Pilih Tingkat / Lain-lain", + style: TextStyle(fontFamily: 'Inter', fontSize: 14, color: Color(0xFFC4C4C4)), + ), + decoration: _buildDecoration("").copyWith(hintText: null), + items: [ + for (int i = 1; i <= 6; i++) + DropdownMenuItem(value: i, child: Text("Tingkat $i", style: const TextStyle(fontFamily: 'Inter', fontSize: 14))), + const DropdownMenuItem(value: 7, child: Text("Lain-lain", style: TextStyle(fontFamily: 'Inter', fontSize: 14))), + ], + onChanged: (value) { + setState(() { + selectedTingkat = value; + }); + }, + ), + const SizedBox(height: 16), + + + + // Tahun Ajaran + _buildLabel("Tahun Ajaran"), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: TextField( + controller: tahunAwalController, + keyboardType: TextInputType.number, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("2024"), + textAlign: TextAlign.center, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + "/", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 24, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ), + Expanded( + child: TextField( + controller: tahunAkhirController, + keyboardType: TextInputType.number, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("2025"), + textAlign: TextAlign.center, + ), + ), + ], + ), + const SizedBox(height: 24), + + ElevatedButton( + onPressed: isLoading ? null : simpan, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + elevation: 0, + ), + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Text( + "Simpan", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_kelas/update_kelas.dart b/lib/dashboard/a_admin/function/f_kelas/update_kelas.dart new file mode 100644 index 0000000..9e1f05b --- /dev/null +++ b/lib/dashboard/a_admin/function/f_kelas/update_kelas.dart @@ -0,0 +1,471 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_kelas.dart'; +import 'package:apk/dashboard/a_admin/function/f_murid/form_murid.dart'; +import 'package:apk/dashboard/a_admin/function/f_guru/form_guru.dart'; + +class UpdateKelasPage extends StatefulWidget { + final Map classData; + + const UpdateKelasPage({super.key, required this.classData}); + + @override + State createState() => _UpdateKelasPageState(); +} + +class _UpdateKelasPageState extends State { + final nameController = TextEditingController(); + final levelController = TextEditingController(); + final tahunController = TextEditingController(); + + bool isLoading = false; + + List> muridInClass = []; + List> guruInClass = []; + + List> muridUnassigned = []; + List> guruUnassigned = []; + + @override + void initState() { + super.initState(); + nameController.text = widget.classData['name_class']?.toString() ?? ''; + levelController.text = widget.classData['id_class']?.toString() ?? ''; + tahunController.text = widget.classData['tahun']?.toString() ?? ''; + + fetchData(); + } + + Future fetchData() async { + setState(() => isLoading = true); + try { + final idTabel = widget.classData['id_tabel']; + final mc = await ClassService.getMuridByClass(idTabel); + final gc = await ClassService.getGuruByClass(idTabel); + final mu = await ClassService.getMuridUnassigned(); + final gu = await ClassService.getGuruUnassigned(); + + setState(() { + muridInClass = mc; + guruInClass = gc; + muridUnassigned = mu; + guruUnassigned = gu; + }); + } catch (e) { + print("Error fetching update data: $e"); + } finally { + if (mounted) setState(() => isLoading = false); + } + } + + Future simpanInfoKelas() async { + if (nameController.text.isEmpty || levelController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Nama Kelas dan Tingkat Kelas wajib diisi!")), + ); + return; + } + + setState(() => isLoading = true); + try { + await ClassService.updateKelas( + idTabel: widget.classData['id_tabel'], + idClass: int.tryParse(levelController.text) ?? 1, + nameClass: nameController.text, + tahun: tahunController.text.isEmpty ? null : tahunController.text, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Info kelas berhasil diupdate")), + ); + Navigator.pop(context, { + 'name_class': nameController.text, + 'id_class': int.tryParse(levelController.text) ?? 1, + 'tahun': tahunController.text.isEmpty ? null : tahunController.text, + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Gagal mengupdate info: $e")), + ); + } finally { + if (mounted) setState(() => isLoading = false); + } + } + + Future hapusKelas() async { + bool confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Hapus Kelas"), + content: const Text("Yakin ingin menghapus kelas ini? Semua guru dan murid di kelas ini akan dikeluarkan dari kelas."), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text("Batal"), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text("Hapus", style: TextStyle(color: Colors.red)), + ), + ], + ), + ) ?? false; + + if (!confirm) return; + + setState(() => isLoading = true); + try { + await ClassService.deleteKelas(widget.classData['id_tabel']); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Kelas berhasil dihapus")), + ); + Navigator.pop(context); // close update page + Navigator.pop(context); // close detail page, back to list + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Gagal menghapus kelas: $e")), + ); + setState(() => isLoading = false); + } + } + + void assignGuru(String idGuru, bool asWali) async { + setState(() => isLoading = true); + try { + await ClassService.assignGuruToClass(idGuru, widget.classData['id_tabel'], isWali: asWali); + await fetchData(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Gagal assign guru: $e"))); + setState(() => isLoading = false); + } + } + + void unassignGuru(String idGuru) async { + setState(() => isLoading = true); + try { + await ClassService.removeGuruFromClass(idGuru); + await fetchData(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Gagal remove guru: $e"))); + setState(() => isLoading = false); + } + } + + void assignMurid(String idMurid) async { + setState(() => isLoading = true); + try { + await ClassService.assignMuridToClass(idMurid, widget.classData['id_tabel']); + await fetchData(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Gagal assign murid: $e"))); + setState(() => isLoading = false); + } + } + + void unassignMurid(String idMurid) async { + setState(() => isLoading = true); + try { + await ClassService.removeMuridFromClass(idMurid); + await fetchData(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Gagal remove murid: $e"))); + setState(() => isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Update Kelas"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(20), + children: [ + _buildSectionTitle("Informasi Kelas"), + _buildInfoForm(), + const SizedBox(height: 30), + + _buildSectionTitle( + "Kelola Guru", + onAdd: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const FormGuruPage())).then((_) => fetchData()); + }, + ), + _buildGuruList(), + + const SizedBox(height: 30), + _buildSectionTitle( + "Kelola Murid", + onAdd: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const FormMuridPage())).then((_) => fetchData()); + }, + ), + _buildMuridList(), + + const SizedBox(height: 40), + ElevatedButton( + onPressed: hapusKelas, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[50], + foregroundColor: Colors.red[700], + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.red[200]!), + ), + ), + child: const Text("Hapus Kelas", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 20), + ], + ), + ); + } + + Widget _buildSectionTitle(String title, {VoidCallback? onAdd}) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + if (onAdd != null) + TextButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 18), + label: const Text("Buat Baru"), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF2563EB), + padding: EdgeInsets.zero, + minimumSize: const Size(50, 30), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ); + } + + Widget _buildInfoForm() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Nama Kelas"), + const SizedBox(height: 6), + TextField( + controller: nameController, + decoration: _inputDecoration("Contoh: Kelas 1A"), + ), + const SizedBox(height: 16), + + const Text("Tingkat Kelas (Angka)"), + const SizedBox(height: 6), + TextField( + controller: levelController, + keyboardType: TextInputType.number, + decoration: _inputDecoration("Contoh: 1"), + ), + const SizedBox(height: 16), + + const Text("Tahun Ajaran"), + const SizedBox(height: 6), + TextField( + controller: tahunController, + decoration: _inputDecoration("Contoh: 2023/2024"), + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: simpanInfoKelas, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text("Simpan Perubahan", style: TextStyle(color: Colors.white, fontSize: 16)), + ), + ), + ], + ), + ); + } + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ); + } + + Widget _buildGuruList() { + 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, 4), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (guruInClass.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text("Guru di kelas ini:", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)), + ), + ...guruInClass.map((g) => ListTile( + title: Text(g['name'] ?? ''), + subtitle: Text(g['wali'] == true ? "Wali Kelas" : "Guru Mapel"), + trailing: IconButton( + icon: const Icon(Icons.remove_circle, color: Colors.red), + onPressed: () => unassignGuru(g['id_tabel']), + ), + )), + const Divider(), + ], + + if (guruUnassigned.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text("Tambahkan Guru:", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green)), + ), + ...guruUnassigned.map((g) => ListTile( + title: Text(g['name'] ?? ''), + subtitle: Text("Mapel: ${g['bidang'] ?? '-'}"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => assignGuru(g['id_tabel'], true), + child: const Text("Jadikan Wali"), + ), + IconButton( + icon: const Icon(Icons.add_circle, color: Colors.green), + onPressed: () => assignGuru(g['id_tabel'], false), + ), + ], + ), + )), + ] else if (guruInClass.isEmpty) ...[ + const Padding( + padding: EdgeInsets.all(16), + child: Text("Tidak ada guru yang tersedia."), + ) + ] + ], + ), + ); + } + + Widget _buildMuridList() { + 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, 4), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (muridInClass.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text("Murid di kelas ini:", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)), + ), + ...muridInClass.map((m) => ListTile( + title: Text(m['nama'] ?? ''), + subtitle: Text("NIS: ${m['nis'] ?? '-'}"), + trailing: IconButton( + icon: const Icon(Icons.remove_circle, color: Colors.red), + onPressed: () => unassignMurid(m['id_tabel']), + ), + )), + const Divider(), + ], + + if (muridUnassigned.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text("Tambahkan Murid:", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green)), + ), + ...muridUnassigned.map((m) => ListTile( + title: Text(m['nama'] ?? ''), + subtitle: Text("NIS: ${m['nis'] ?? '-'}"), + trailing: IconButton( + icon: const Icon(Icons.add_circle, color: Colors.green), + onPressed: () => assignMurid(m['id_tabel']), + ), + )), + ] else if (muridInClass.isEmpty) ...[ + const Padding( + padding: EdgeInsets.all(16), + child: Text("Tidak ada murid yang tersedia."), + ) + ] + ], + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_murid/detai_murid.dart b/lib/dashboard/a_admin/function/f_murid/detai_murid.dart new file mode 100644 index 0000000..680c739 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_murid/detai_murid.dart @@ -0,0 +1,493 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_siswa.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/a_admin/function/f_murid/update_murid.dart'; +import 'package:apk/dashboard/a_admin/function/f_alat/config_user.dart'; + +class DetailMuridPage extends StatefulWidget { + final Map data; + + const DetailMuridPage({ + super.key, + required this.data, + }); + + @override + State createState() => _DetailMuridPageState(); +} + +class _DetailMuridPageState extends State { + final service = MuridService(); + late Map currentData; + + bool _isLoadingStatus = true; + bool _hasFace = false; + bool _hasRFID = false; + String _rfidUid = ''; + String _serverUrl = 'http://192.168.100.16:8000'; + + @override + void initState() { + super.initState(); + currentData = Map.from(widget.data); + _fetchStatus(); + } + + Future _fetchStatus() async { + try { + final prefs = await SharedPreferences.getInstance(); + _serverUrl = (prefs.getString('face_server_url') ?? 'http://192.168.100.16:8000').replaceAll(RegExp(r'/+$'), ''); + + final res = await http.get(Uri.parse('$_serverUrl/api/persons')).timeout(const Duration(seconds: 7)); + if (res.statusCode == 200) { + final data = json.decode(res.body); + final items = data['items'] as List; + final nameToFind = currentData['nama']?.toString() ?? ""; + final match = items.where((e) => e['person'] == nameToFind).toList(); + if (match.isNotEmpty) { + _hasFace = match.first['has_face'] == true; + _rfidUid = match.first['rfid_uid']?.toString() ?? ''; + _hasRFID = _rfidUid.isNotEmpty; + } + } + } catch (e) { + debugPrint('Error fetch status alat: $e'); + } + if (mounted) { + setState(() { + _isLoadingStatus = false; + }); + } + } + + Future deleteData() async { + try { + await service.deleteMurid(currentData['id_tabel']); + if (!mounted) return; + Navigator.pop(context, true); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))), + ); + } + } + + String get _nama => currentData['nama']?.toString().isEmpty ?? true ? "Belum diisi" : currentData['nama']; + String get _nis => currentData['nis']?.toString().isEmpty ?? true ? "Belum diisi" : currentData['nis'].toString(); + String get _kelas { + final kelas = currentData['class_name']?['name_class']?.toString() ?? ""; + return kelas.isEmpty ? "Belum ada kelas" : kelas; + } + + String _displayValue(String label, String? value) { + if (value == null || value.isEmpty) return "Belum diisi"; + if (label != "Tanggal Lahir") return value; + + final date = DateTime.tryParse(value); + if (date == null) return value; + + final day = date.day.toString().padLeft(2, '0'); + final month = date.month.toString().padLeft(2, '0'); + return "$day-$month-${date.year}"; + } + + IconData _iconForLabel(String label) { + switch (label) { + case "Wajah": + return Icons.face; + case "RFID": + return Icons.credit_card; + case "NIS": + return Icons.badge_outlined; + case "Nama": + return Icons.person_outline; + case "Gender": + return Icons.person_pin_outlined; + case "Tanggal Lahir": + return Icons.calendar_month_outlined; + case "Alamat": + return Icons.location_on_outlined; + case "Orang Tua": + return Icons.groups_2_outlined; + case "No Telepon": + return Icons.phone_outlined; + default: + return Icons.info_outline; + } + } + + Widget _infoCard({ + required IconData icon, + required String title, + required String value, + bool isEmpty = false, + }) { + return CustomCard( + title: title, + subtitle: value, + icon: icon, + iconPosition: IconPosition.left, + iconColor: const Color(0xFF2563EB), + iconSize: 24, + iconContainerSize: 46, + iconDecoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(9), + ), + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + borderRadius: 0, + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + boxShadow: const [], + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF8FA0BA), + ), + subtitleStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + fontStyle: isEmpty ? FontStyle.italic : FontStyle.normal, + color: isEmpty ? const Color(0xFFC8D2E0) : const Color(0xFF172033), + ), + showIcon: true, + ); + } + + Widget _sectionCard(List children) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8EEF6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) + const Divider( + height: 1, + thickness: 1, + color: Color(0xFFF0F4FA), + indent: 82, + endIndent: 20, + ), + ], + ], + ), + ), + ); + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 12), + child: Text( + title, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w800, + color: Color(0xFF64748B), + ), + ), + ); + } + + Widget _profileHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE8EEF6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _nama, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w800, + color: Color(0xFF172033), + ), + ), + const SizedBox(height: 16), + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 16, + runSpacing: 10, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.badge_outlined, + size: 18, + color: Color(0xFF8FA0BA), + ), + const SizedBox(width: 6), + Text( + "NIS: $_nis", + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.school_outlined, + size: 18, + color: Color(0xFF2563EB), + ), + const SizedBox(width: 6), + Text( + _kelas, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF2563EB), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + }) { + final bool isWhite = color == Colors.white; + return Expanded( + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: isWhite ? const Color(0xFF64748B) : Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: isWhite ? const BorderSide(color: Color(0xFF94A3B8)) : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: isWhite ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final gender = currentData['gender']?.toString() ?? ""; + final tglLahir = currentData['tanggal_lahir']?.toString() ?? ""; + final alamat = currentData['alamat']?.toString() ?? ""; + final ortu = currentData['orang_tua']?.toString() ?? ""; + final tele = currentData['no_tele']?.toString() ?? ""; + + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Detail Murid"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), + children: [ + _profileHeader(), + const SizedBox(height: 20), + _sectionTitle("Identitas"), + _sectionCard([ + _infoCard( + icon: _iconForLabel("Gender"), + title: "Gender", + value: _displayValue("Gender", gender), + isEmpty: gender.isEmpty, + ), + _infoCard( + icon: _iconForLabel("Tanggal Lahir"), + title: "Tanggal Lahir", + value: _displayValue("Tanggal Lahir", tglLahir), + isEmpty: tglLahir.isEmpty, + ), + _infoCard( + icon: _iconForLabel("Alamat"), + title: "Alamat", + value: _displayValue("Alamat", alamat), + isEmpty: alamat.isEmpty, + ), + ]), + const SizedBox(height: 18), + _sectionTitle("Status Registrasi Alat"), + _sectionCard([ + _infoCard( + icon: _iconForLabel("Wajah"), + title: "Wajah", + value: _isLoadingStatus ? "Loading..." : (_hasFace ? "Terdaftar" : "Belum Terdaftar"), + isEmpty: !_hasFace && !_isLoadingStatus, + ), + _infoCard( + icon: _iconForLabel("RFID"), + title: "RFID", + value: _isLoadingStatus ? "Loading..." : (_hasRFID ? _rfidUid : "Belum Terdaftar"), + isEmpty: !_hasRFID && !_isLoadingStatus, + ), + ]), + const SizedBox(height: 18), + _sectionTitle("Kontak Orang Tua"), + _sectionCard([ + _infoCard( + icon: _iconForLabel("Orang Tua"), + title: "Orang Tua", + value: _displayValue("Orang Tua", ortu), + isEmpty: ortu.isEmpty, + ), + _infoCard( + icon: _iconForLabel("No Telepon"), + title: "No Telepon", + value: _displayValue("No Telepon", tele), + isEmpty: tele.isEmpty, + ), + ]), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ConfigUser(initialUid: currentData['nama']?.toString()), + ), + ); + }, + icon: const Icon(Icons.face_retouching_natural, color: Colors.white, size: 20), + label: const Text( + "Hubungkan ke Alat (Set Identitas)", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), // Emerald green + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + minimumSize: const Size(double.infinity, 50), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + _actionButton( + label: "Edit", + color: const Color(0xFF2563EB), + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => UpdateMuridPage(data: currentData), + ), + ); + if (result != null && mounted) { + setState(() { + currentData = result; + }); + } + }, + ), + const SizedBox(width: 10), + _actionButton( + label: "Delete", + color: Colors.red, + onPressed: deleteData, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_murid/false_delete.dart b/lib/dashboard/a_admin/function/f_murid/false_delete.dart new file mode 100644 index 0000000..c123e76 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_murid/false_delete.dart @@ -0,0 +1,277 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_siswa.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; + +class FalseDeleteMuridPage extends StatefulWidget { + const FalseDeleteMuridPage({super.key}); + + @override + State createState() => _FalseDeleteMuridPageState(); +} + +class _FalseDeleteMuridPageState extends State { + final service = MuridService(); + List> deletedMuridList = []; + bool isLoading = true; + String? errorMessage; + + @override + void initState() { + super.initState(); + loadData(); + } + + Future loadData() async { + try { + final data = await service.getDeletedMurid(); + if (mounted) { + setState(() { + deletedMuridList = data; + isLoading = false; + errorMessage = null; + }); + } + } catch (e) { + if (mounted) { + setState(() { + isLoading = false; + errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } + } + + Future restoreMurid(String idTabel, String name) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Pulihkan Murid"), + content: Text("Apakah Anda yakin ingin memulihkan data murid $name?"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text("Batal"), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF2563EB)), + child: const Text("Pulihkan", style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirm != true) return; + + setState(() => isLoading = true); + try { + await service.restoreMurid(idTabel); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Data murid $name berhasil dipulihkan")), + ); + } + loadData(); + } catch (e) { + if (mounted) { + setState(() => isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Gagal memulihkan: ${e.toString().replaceAll('Exception: ', '')}")), + ); + } + } + } + + Future deletePermanentlyMurid(String idTabel, String name) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Hapus Permanen"), + content: Text("Apakah Anda yakin ingin menghapus permanen data murid $name? Tindakan ini tidak dapat dibatalkan."), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text("Batal"), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text("Hapus", style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirm != true) return; + + setState(() => isLoading = true); + try { + await service.deleteMuridPermanently(idTabel); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Data murid $name berhasil dihapus permanen")), + ); + } + loadData(); + } catch (e) { + if (mounted) { + setState(() => isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Gagal menghapus: ${e.toString().replaceAll('Exception: ', '')}")), + ); + } + } + } + + String safe(dynamic value) { + if (value == null || value.toString().isEmpty) return "N/A"; + return value.toString(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Tempat Sampah Murid"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: RefreshIndicator( + onRefresh: loadData, + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : errorMessage != null + ? ListView( + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.3), + Center( + child: Text( + "Terjadi Kesalahan:\n$errorMessage", + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ) + : deletedMuridList.isEmpty + ? ListView( + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.35), + const Center( + child: Text( + "Tidak ada data murid yang dihapus", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Colors.grey, + ), + ), + ), + ], + ) + : ListView.builder( + itemCount: deletedMuridList.length, + itemBuilder: (context, index) { + final m = deletedMuridList[index]; + final nama = safe(m['nama']); + final nis = safe(m['nis']); + final kelas = m['class_name']?['name_class'] ?? 'Belum ada kelas'; + + return Column( + children: [ + if (index == 0) const SizedBox(height: 12), + CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + 'NIS: $nis', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Kelas: $kelas', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + customIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => restoreMurid(m['id_tabel'], nama), + borderRadius: BorderRadius.circular(20), + child: const Padding( + padding: EdgeInsets.all(6.0), + child: Icon(Icons.restore, color: Colors.green, size: 22), + ), + ), + const SizedBox(width: 4), + InkWell( + onTap: () => deletePermanentlyMurid(m['id_tabel'], nama), + borderRadius: BorderRadius.circular(20), + child: const Padding( + padding: EdgeInsets.all(6.0), + child: Icon(Icons.delete_forever, color: Colors.red, size: 22), + ), + ), + ], + ), + showIcon: true, + iconContainerSize: 80, + iconPadding: EdgeInsets.zero, + iconDecoration: const BoxDecoration(), + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_murid/form_murid.dart b/lib/dashboard/a_admin/function/f_murid/form_murid.dart new file mode 100644 index 0000000..b89882b --- /dev/null +++ b/lib/dashboard/a_admin/function/f_murid/form_murid.dart @@ -0,0 +1,375 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_siswa.dart'; +import 'package:apk/dashboard/a_admin/function/f_kelas/form_kelas..dart'; + +class FormMuridPage extends StatefulWidget { + const FormMuridPage({super.key}); + + @override + State createState() => _FormMuridPageState(); +} + +class _FormMuridPageState extends State { + final MuridService service = MuridService(); + + final nisController = TextEditingController(); + final namaController = TextEditingController(); + final alamatController = TextEditingController(); + final orangTuaController = TextEditingController(); + final noTeleController = TextEditingController(); + final tempatLahirController = TextEditingController(); + + List> kelasList = []; + String? selectedClass; + String? selectedGender; + DateTime? selectedDate; + + bool isLoading = false; + + @override + void initState() { + super.initState(); + loadKelas(); + } + + Future loadKelas() async { + final data = await service.getKelas(); + setState(() { + kelasList = data; + }); + } + + Future pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime(2010), + firstDate: DateTime(1990), + lastDate: DateTime.now(), + ); + + if (picked != null) { + setState(() { + selectedDate = picked; + }); + } + } + + Future simpan() async { + if (nisController.text.isEmpty || + namaController.text.isEmpty || + selectedClass == null || + selectedGender == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Field wajib belum diisi!")), + ); + return; + } + + setState(() => isLoading = true); + + try { + await service.addMurid( + nis: nisController.text, + nama: namaController.text, + idClass: selectedClass, + gender: selectedGender, + tanggalLahir: selectedDate, + alamat: alamatController.text, + orangTua: orangTuaController.text, + noTele: num.tryParse(noTeleController.text), + ); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Data berhasil disimpan")), + ); + + // reset form + nisController.clear(); + namaController.clear(); + alamatController.clear(); + orangTuaController.clear(); + noTeleController.clear(); + tempatLahirController.clear(); + + setState(() { + selectedClass = null; + selectedGender = null; + selectedDate = null; + }); + + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))), + ); + } + + setState(() => isLoading = false); + } + + Widget _buildLabel(String text) { + return Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ); + } + + InputDecoration _buildDecoration(String hintText) { + return InputDecoration( + hintText: hintText, + hintStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', + width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Form Murid"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 10), + child: ListView( + children: [ + // NIS + _buildLabel("NIS *"), + const SizedBox(height: 6), + TextField( + controller: nisController, + keyboardType: TextInputType.number, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Masukkan Nomor Induk Siswa"), + ), + const SizedBox(height: 16), + + // Nama + _buildLabel("Nama Lengkap *"), + const SizedBox(height: 6), + TextField( + controller: namaController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Nama lengkap sesuai akta"), + ), + const SizedBox(height: 16), + + // Kelas + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildLabel("Kelas *"), + TextButton( + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FormKelasPage()), + ); + loadKelas(); + }, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + "Buat Kelas", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF2563EB), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + DropdownButtonFormField( + isExpanded: true, + value: selectedClass, + hint: const Text( + "Pilih Kelas", + style: TextStyle(fontFamily: 'Inter', fontSize: 12, color: Color(0xFFC4C4C4)), + ), + decoration: _buildDecoration("").copyWith(hintText: null), + items: kelasList.map((kelas) { + return DropdownMenuItem( + value: kelas['id_tabel'], + child: Text( + kelas['name_class'], + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + ), + ); + }).toList(), + onChanged: (value) { + setState(() { + selectedClass = value; + }); + }, + ), + const SizedBox(height: 16), + + // Gender + _buildLabel("Jenis Kelamin *"), + const SizedBox(height: 6), + DropdownButtonFormField( + value: selectedGender, + hint: const Text( + "Pilih Jenis Kelamin", + style: TextStyle(fontFamily: 'Inter', fontSize: 12, color: Color(0xFFC4C4C4)), + ), + decoration: _buildDecoration("").copyWith(hintText: null), + items: const [ + DropdownMenuItem( + value: 'L', + child: Text("Laki-laki", style: TextStyle(fontFamily: 'Inter', fontSize: 14)), + ), + DropdownMenuItem( + value: 'P', + child: Text("Perempuan", style: TextStyle(fontFamily: 'Inter', fontSize: 14)), + ), + ], + onChanged: (value) { + setState(() { + selectedGender = value; + }); + }, + ), + const SizedBox(height: 16), + + // Tempat Lahir + _buildLabel("Tempat Lahir"), + const SizedBox(height: 6), + TextField( + controller: tempatLahirController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Kota/Kabupaten kelahiran"), + ), + const SizedBox(height: 16), + + // Tanggal Lahir + _buildLabel("Tanggal Lahir"), + const SizedBox(height: 6), + InkWell( + onTap: pickDate, + child: InputDecorator( + decoration: _buildDecoration(""), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedDate == null + ? "mm/dd/yyyy" + : selectedDate.toString().split(' ')[0], + style: TextStyle( + fontFamily: 'Inter', + fontSize: selectedDate == null ? 12 : 14, + color: selectedDate == null ? const Color(0xFFC4C4C4) : Colors.black, + ), + ), + const Icon(Icons.calendar_today, size: 18, color: Color(0xFF64748B)), + ], + ), + ), + ), + const SizedBox(height: 16), + + + + // Alamat + _buildLabel("Alamat"), + const SizedBox(height: 6), + TextField( + controller: alamatController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Masukkan alamat"), + ), + const SizedBox(height: 16), + + // Orang Tua + _buildLabel("Orang Tua"), + const SizedBox(height: 6), + TextField( + controller: orangTuaController, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Nama orang tua"), + ), + const SizedBox(height: 16), + + // No HP + _buildLabel("No Telepon"), + const SizedBox(height: 6), + TextField( + controller: noTeleController, + keyboardType: TextInputType.number, + style: const TextStyle(fontFamily: 'Inter', fontSize: 14), + decoration: _buildDecoration("Masukkan nomor telepon"), + ), + const SizedBox(height: 24), + + ElevatedButton( + onPressed: isLoading ? null : simpan, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + elevation: 0, + ), + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Text( + "Simpan", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/function/f_murid/get_murid.dart b/lib/dashboard/a_admin/function/f_murid/get_murid.dart new file mode 100644 index 0000000..452df10 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_murid/get_murid.dart @@ -0,0 +1,203 @@ +import 'package:apk/dashboard/a_admin/function/f_murid/detai_murid.dart'; +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_siswa.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; + +import 'package:apk/dashboard/a_admin/function/f_murid/false_delete.dart'; + +class MuridPage extends StatefulWidget { + final bool isSelectionMode; + const MuridPage({super.key, this.isSelectionMode = false}); + + @override + State createState() => _MuridPageState(); +} + +class _MuridPageState extends State { + final MuridService service = MuridService(); + + List> muridList = []; + bool isLoading = true; + + @override + void initState() { + super.initState(); + loadData(); + } + + String? errorMessage; + + Future loadData() async { + try { + final data = await service.getMurid(); + setState(() { + muridList = data; + isLoading = false; + errorMessage = null; + }); + } catch (e) { + setState(() { + isLoading = false; + errorMessage = e.toString(); + }); + } + } + + String safe(dynamic value) { + if (value == null || value.toString().isEmpty) return "N/A"; + return value.toString(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Manajemen Murid"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + actions: [ + IconButton( + icon: const Icon(Icons.restore_from_trash, color: Colors.white), + tooltip: 'Tempat Sampah', + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const FalseDeleteMuridPage()), + ); + loadData(); + }, + ), + ], + ), + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + "Terjadi Kesalahan:\n$errorMessage", + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + ) + : muridList.isEmpty + ? const Center( + child: Text( + "Belum ada data murid", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Colors.grey, + ), + ), + ) + : Column( + children: [ + const SizedBox(height: 12), + Expanded( + child: ListView.builder( + itemCount: muridList.length, + itemBuilder: (context, index) { + final m = muridList[index]; + final nama = safe(m['nama']); + final nis = safe(m['nis']); + final kelas = + m['class_name']?['name_class'] ?? + 'Belum ada kelas'; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), + child: CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + 'NIS: $nis', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Kelas: $kelas', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + //padding: const EdgeInsets.all(20), + onTap: () async { + if (widget.isSelectionMode) { + Navigator.pop(context, m); + return; + } + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + DetailMuridPage(data: m), + ), + ); + + // refresh setelah balik + loadData(); + }, + ), + ), + const SizedBox(height: 12), + ], + ); + }, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/function/f_murid/update_murid.dart b/lib/dashboard/a_admin/function/f_murid/update_murid.dart new file mode 100644 index 0000000..985ac09 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_murid/update_murid.dart @@ -0,0 +1,451 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_siswa.dart'; + +class UpdateMuridPage extends StatefulWidget { + final Map data; + + const UpdateMuridPage({ + super.key, + required this.data, + }); + + @override + State createState() => _UpdateMuridPageState(); +} + +class _UpdateMuridPageState extends State { + final service = MuridService(); + + bool isSaving = false; + + late TextEditingController nisC; + late TextEditingController namaC; + late TextEditingController genderC; + late TextEditingController tglC; + late TextEditingController alamatC; + late TextEditingController ortuC; + late TextEditingController teleC; + + List> kelasList = []; + String? selectedKelas; + + @override + void initState() { + super.initState(); + + nisC = TextEditingController(text: widget.data['nis']?.toString() ?? ""); + namaC = TextEditingController(text: widget.data['nama'] ?? ""); + genderC = TextEditingController(text: widget.data['gender'] ?? ""); + tglC = TextEditingController( + text: widget.data['tanggal_lahir']?.toString() ?? "", + ); + alamatC = TextEditingController(text: widget.data['alamat'] ?? ""); + ortuC = TextEditingController(text: widget.data['orang_tua'] ?? ""); + teleC = TextEditingController( + text: widget.data['no_tele']?.toString() ?? "", + ); + + selectedKelas = widget.data['id_class']; + + loadKelas(); + } + + Future loadKelas() async { + final data = await service.getKelas(); + if (mounted) { + setState(() => kelasList = data); + } + } + + Future updateData() async { + setState(() => isSaving = true); + + try { + await service.updateMurid( + idTabel: widget.data['id_tabel'], + nis: nisC.text, + nama: namaC.text, + idClass: selectedKelas, + gender: genderC.text, + tanggalLahir: tglC.text.isEmpty ? null : DateTime.tryParse(tglC.text), + alamat: alamatC.text, + orangTua: ortuC.text, + noTele: num.tryParse(teleC.text), + ); + + final selectedKelasData = kelasList.where( + (kelas) => kelas['id_tabel'] == selectedKelas, + ); + + if (mounted) { + final Map updatedData = Map.from(widget.data); + updatedData['nis'] = nisC.text; + updatedData['nama'] = namaC.text; + updatedData['id_class'] = selectedKelas; + updatedData['gender'] = genderC.text; + updatedData['tanggal_lahir'] = tglC.text; + updatedData['alamat'] = alamatC.text; + updatedData['orang_tua'] = ortuC.text; + updatedData['no_tele'] = num.tryParse(teleC.text); + updatedData['class_name'] = selectedKelasData.isEmpty + ? null + : { + 'id_tabel': selectedKelasData.first['id_tabel'], + 'name_class': selectedKelasData.first['name_class'], + }; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Data murid berhasil diperbarui')), + ); + Navigator.pop(context, updatedData); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Gagal menyimpan: ${e.toString().replaceAll('Exception: ', '')}", + ), + ), + ); + } + } finally { + if (mounted) { + setState(() => isSaving = false); + } + } + } + + Future _pickDate() async { + final initialDate = DateTime.tryParse(tglC.text) ?? DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (picked != null) { + setState(() { + tglC.text = "${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}"; + }); + } + } + + Widget field(String label, TextEditingController c, {String hint = "", VoidCallback? onTap, bool readOnly = false}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + TextField( + controller: c, + onTap: onTap, + readOnly: readOnly, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget kelasField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Kelas", + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + DropdownButtonFormField( + value: selectedKelas, + decoration: InputDecoration( + hintText: "Pilih Kelas", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + items: kelasList.map((e) { + return DropdownMenuItem( + value: e['id_tabel'], + child: Text(e['name_class'], style: const TextStyle(fontFamily: 'Inter')), + ); + }).toList(), + onChanged: (v) => setState(() => selectedKelas = v), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget genderField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Gender", + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + DropdownButtonFormField( + isExpanded: true, + value: genderC.text.isEmpty ? null : genderC.text, + decoration: InputDecoration( + hintText: "Pilih Gender", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + items: const [ + DropdownMenuItem(value: "L", child: Text("L", style: TextStyle(fontFamily: 'Inter'))), + DropdownMenuItem(value: "P", child: Text("P", style: TextStyle(fontFamily: 'Inter'))), + ], + onChanged: (v) => setState(() => genderC.text = v ?? ""), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + }) { + final bool isWhite = color == Colors.white; + return Expanded( + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: isWhite ? const Color(0xFF64748B) : Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: isWhite ? const BorderSide(color: Color(0xFF94A3B8)) : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: isWhite ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + void dispose() { + nisC.dispose(); + namaC.dispose(); + genderC.dispose(); + tglC.dispose(); + alamatC.dispose(); + ortuC.dispose(); + teleC.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Edit Data Murid"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: Container( + color: Colors.white, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Expanded( + child: ListView( + children: [ + Row( + children: const [ + Text("📋", style: TextStyle(fontSize: 16)), + SizedBox(width: 8), + Text( + "Data Utama", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("NIS *", nisC, hint: "Contoh: 332817"), + field("Nama *", namaC, hint: "Contoh: Budi Santoso"), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: genderField()), + const SizedBox(width: 16), + Expanded( + child: field( + "Tanggal Lahir", + tglC, + hint: "YYYY-MM-DD", + readOnly: true, + onTap: _pickDate, + ), + ), + ], + ), + kelasField(), + ], + ), + ), + + const SizedBox(height: 24), + + Row( + children: const [ + Text("📚", style: TextStyle(fontSize: 16)), + SizedBox(width: 8), + Text( + "Kontak & Orang Tua", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("Alamat", alamatC, hint: "Masukkan alamat"), + field("Orang Tua", ortuC, hint: "Nama orang tua"), + field("No Telepon", teleC, hint: "Nomor telepon"), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + _actionButton( + label: "Simpan", + color: const Color(0xFF2563EB), + onPressed: isSaving ? null : updateData, + loading: isSaving, + ), + const SizedBox(width: 10), + _actionButton( + label: "Batal", + color: Colors.white, + onPressed: isSaving ? null : () => Navigator.pop(context), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_notif/notif_reddot.dart b/lib/dashboard/a_admin/function/f_notif/notif_reddot.dart new file mode 100644 index 0000000..aa62f55 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_notif/notif_reddot.dart @@ -0,0 +1,261 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/a_admin/database/db_grup_sekolah.dart'; +import 'package:apk/dashboard/a_admin/database/db_pesan_private.dart'; + +class NotifRedDot { + static final GlobalKey navigatorKey = GlobalKey(); + + // Notifiers untuk indikator titik merah (red dot) + static final ValueNotifier grupSekolahHasUnread = ValueNotifier(false); + static final ValueNotifier chatPrivateHasUnread = ValueNotifier(false); + static final ValueNotifier> chatPrivateUnreadSenderIds = ValueNotifier>({}); + + static String? _currentUserId; + static String? _currentAdminId; + + static StreamSubscription>>? _grupSekolahSubscription; + static StreamSubscription>>? _chatPrivateSubscription; + + static DateTime? _grupSekolahLastSeenAt; + static DateTime? _chatPrivateLastSeenAt; + + /// Inisialisasi listener realtime Supabase untuk titik merah (Grup Sekolah & Chat Private) + static Future init({GlobalKey? key}) async { + await dispose(); + + // Load saved unread state + try { + final prefs = await SharedPreferences.getInstance(); + grupSekolahHasUnread.value = prefs.getBool('grup_sekolah_has_unread') ?? false; + + final unreadList = prefs.getStringList('chat_private_unread_senders') ?? []; + chatPrivateUnreadSenderIds.value = unreadList.toSet(); + chatPrivateHasUnread.value = chatPrivateUnreadSenderIds.value.isNotEmpty || (prefs.getBool('chat_private_has_unread') ?? false); + } catch (e) { + debugPrint('[NotifRedDot] load state error: $e'); + } + + // Init Grup Sekolah Realtime + try { + final info = await DbGrupSekolah.getCurrentUserRoleAndId(); + if (info != null) { + _currentUserId = info['id']; + } + _subscribeGrupSekolahRealtime(); + } catch (e) { + debugPrint('[NotifRedDot] GrupSekolah init error: $e'); + } + + // Init Chat Private Realtime + try { + final dbPesan = DbPesanPrivate(); + _currentAdminId = await dbPesan.getAdminId(); + if (_currentAdminId != null) { + _subscribeChatPrivateRealtime(); + } + } catch (e) { + debugPrint('[NotifRedDot] ChatPrivate init error: $e'); + } + } + + // Setters state titik merah + static Future setGrupSekolahUnread(bool value) async { + grupSekolahHasUnread.value = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('grup_sekolah_has_unread', value); + if (!value) { + await prefs.setString('grup_sekolah_last_read', DateTime.now().toUtc().toIso8601String()); + _grupSekolahLastSeenAt = DateTime.now().toUtc(); + } + if (!value) { + await prefs.setString('grup_sekolah_last_read', DateTime.now().toUtc().toIso8601String()); + _grupSekolahLastSeenAt = DateTime.now().toUtc(); + } + } catch (e) { + debugPrint('[NotifRedDot] setGrupSekolahUnread error: $e'); + } + } + + static Future setChatPrivateSenderUnread(String senderId, bool isUnread) async { + final current = Set.from(chatPrivateUnreadSenderIds.value); + if (isUnread) { + current.add(senderId); + } else { + current.remove(senderId); + } + chatPrivateUnreadSenderIds.value = current; + chatPrivateHasUnread.value = current.isNotEmpty; + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList('chat_private_unread_senders', current.toList()); + await prefs.setBool('chat_private_has_unread', current.isNotEmpty); + if (!isUnread) { + await prefs.setString('chat_private_last_read_$senderId', DateTime.now().toUtc().toIso8601String()); + } + if (!isUnread) { + await prefs.setString('chat_private_last_read_$senderId', DateTime.now().toUtc().toIso8601String()); + } + } catch (e) { + debugPrint('[NotifRedDot] setChatPrivateSenderUnread error: $e'); + } + } + + static Future setChatPrivateUnread(bool value) async { + if (!value) { + chatPrivateUnreadSenderIds.value = {}; + chatPrivateHasUnread.value = false; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('chat_private_unread_senders'); + await prefs.setBool('chat_private_has_unread', false); + } catch (e) { + debugPrint('[NotifRedDot] setChatPrivateUnread error: $e'); + } + } else { + chatPrivateHasUnread.value = true; + } + } + + static void _subscribeGrupSekolahRealtime() { + final supabase = Supabase.instance.client; + final user = supabase.auth.currentUser; + if (user == null) return; + + _subscribeGrupSekolahRealtimeInternal(); + } + + static void _subscribeGrupSekolahRealtimeInternal() async { + final supabase = Supabase.instance.client; + final prefs = await SharedPreferences.getInstance(); + final user = supabase.auth.currentUser; + if (user == null) return; + final lastReadStr = prefs.getString('grup_sekolah_last_read'); + _grupSekolahLastSeenAt = lastReadStr != null ? DateTime.parse(lastReadStr) : DateTime.now().toUtc(); + + _grupSekolahSubscription = supabase + .from('grup_sekolah') + .stream(primaryKey: ['id_tabel']) + .eq('user_id', user.id) + .order('created_at', ascending: false) + .listen( + (rows) { + final newMsgs = rows.where((row) { + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + return t != null && t.isAfter(_grupSekolahLastSeenAt!.subtract(const Duration(minutes: 5))); + }).toList(); + + for (final row in rows) { + final pengirimGuru = row['pengirim_guru']?.toString(); + final pengirimMurid = row['pengirim_murid']?.toString(); + final senderId = pengirimGuru ?? pengirimMurid; + + if (senderId != null && senderId != _currentAdminId) { + final lastReadStr = prefs.getString('chat_private_last_read_$senderId'); + final lastRead = lastReadStr != null ? DateTime.parse(lastReadStr) : DateTime.fromMillisecondsSinceEpoch(0); + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + + if (t != null && t.isAfter(lastRead) && !chatPrivateUnreadSenderIds.value.contains(senderId)) { + setChatPrivateSenderUnread(senderId, true); + } + } + } + + + if (newMsgs.isEmpty) return; + + for (final row in newMsgs) { + final t = DateTime.tryParse(row['created_at'].toString()); + if (t != null && t.isAfter(_grupSekolahLastSeenAt!)) _grupSekolahLastSeenAt = t; + } + + final lastRow = newMsgs.first; + final pengirimAdmin = lastRow['pengirim_admin']?.toString(); + final pengirimGuru = lastRow['pengirim_guru']?.toString(); + final pengirimMurid = lastRow['pengirim_murid']?.toString(); + final senderId = pengirimAdmin ?? pengirimGuru ?? pengirimMurid; + + if (senderId != null && senderId == _currentUserId) { + return; + } + + setGrupSekolahUnread(true); + }, + onError: (e) { + debugPrint('[NotifRedDot] GrupSekolah stream error: $e'); + }, + ); + } + + static void _subscribeChatPrivateRealtime() { + final supabase = Supabase.instance.client; + _chatPrivateLastSeenAt = DateTime.now().toUtc(); + + _chatPrivateSubscription = supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: false) + .listen( + (rows) { + final filtered = rows.where((row) { + return row['penerima_admin']?.toString() == _currentAdminId; + }).toList(); + + final newMsgs = filtered.where((row) { + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + return t != null && t.isAfter(_chatPrivateLastSeenAt!); + }).toList(); + + if (newMsgs.isEmpty) return; + + for (final row in newMsgs) { + final t = DateTime.tryParse(row['created_at'].toString()); + if (t != null && t.isAfter(_chatPrivateLastSeenAt!)) _chatPrivateLastSeenAt = t; + } + + final lastRow = newMsgs.first; + final senderGuru = lastRow['pengirim_guru']?.toString(); + final senderMurid = lastRow['pengirim_murid']?.toString(); + final senderId = senderGuru ?? senderMurid; + + if (senderId != null) { + setChatPrivateSenderUnread(senderId, true); + } + }, + onError: (e) { + debugPrint('[NotifRedDot] ChatPrivate stream error: $e'); + }, + ); + } + + static Future dispose() async { + _grupSekolahSubscription?.cancel(); + _grupSekolahSubscription = null; + _chatPrivateSubscription?.cancel(); + _chatPrivateSubscription = null; + _grupSekolahLastSeenAt = null; + _chatPrivateLastSeenAt = null; + } +} + +/// Backward-compatibility wrappers for NotificationFCM & ChatPrivateNotification +class NotificationFCM { + static GlobalKey get navigatorKey => NotifRedDot.navigatorKey; + static ValueNotifier get hasUnread => NotifRedDot.grupSekolahHasUnread; + static Future setUnread(bool value) => NotifRedDot.setGrupSekolahUnread(value); + static Future init() => NotifRedDot.init(); + static Future dispose() => NotifRedDot.dispose(); +} + +class ChatPrivateNotification { + static ValueNotifier get hasUnread => NotifRedDot.chatPrivateHasUnread; + static ValueNotifier> get unreadSenderIds => NotifRedDot.chatPrivateUnreadSenderIds; + static Future setSenderUnread(String senderId, bool isUnread) => NotifRedDot.setChatPrivateSenderUnread(senderId, isUnread); + static Future setUnread(bool value) => NotifRedDot.setChatPrivateUnread(value); + static Future init({GlobalKey? key}) => NotifRedDot.init(key: key); + static Future dispose() => NotifRedDot.dispose(); +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/function/f_notif/preview_pesan/grup_sekolah_p.dart b/lib/dashboard/a_admin/function/f_notif/preview_pesan/grup_sekolah_p.dart new file mode 100644 index 0000000..c6ef811 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_notif/preview_pesan/grup_sekolah_p.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/a_admin/database/db_grup_sekolah.dart'; +import 'package:apk/dashboard/a_admin/function/f_pesan/grup_sekolah.dart'; + +class GrupSekolahP extends StatefulWidget { + const GrupSekolahP({super.key}); + + @override + State createState() => _GrupSekolahPState(); +} + +class _GrupSekolahPState extends State { + late Future _lastVisitedFuture; + + @override + void initState() { + super.initState(); + _lastVisitedFuture = _loadLastVisited(); + } + + Future _loadLastVisited() async { + final prefs = await SharedPreferences.getInstance(); + final lastVisitedStr = prefs.getString('last_visited_grup_sekolah'); + if (lastVisitedStr != null) { + return DateTime.tryParse(lastVisitedStr); + } + return null; + } + + Future _markAsReadAndNavigate() async { + final prefs = await SharedPreferences.getInstance(); + final nowStr = DateTime.now().toIso8601String(); + await prefs.setString('last_visited_grup_sekolah', nowStr); + if (!mounted) return; + // Update the future so the UI reacts + setState(() { + _lastVisitedFuture = Future.value(DateTime.tryParse(nowStr)); + }); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const GrupSekolah()), + ); + } + + @override + Widget build(BuildContext context) { + if (Supabase.instance.client.auth.currentUser == null) { + return const Scaffold( + body: Center(child: Text('Harap login terlebih dahulu.')), + ); + } + + return Scaffold( + backgroundColor: const Color(0xFFF1F5F9), + appBar: AppBar( + title: const Text( + 'Notifikasi', + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + backgroundColor: const Color(0xFF2563EB), + foregroundColor: Colors.white, + elevation: 0, + ), + body: FutureBuilder( + future: _lastVisitedFuture, + builder: (context, prefSnapshot) { + // Tampilkan loading saat SharedPreferences belum siap + if (prefSnapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final lastVisited = prefSnapshot.data; + + return StreamBuilder>>( + stream: DbGrupSekolah().getGrupSekolahStream(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting && + !snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Text('Error: ${snapshot.error}'), + ); + } + + int unreadCount = 0; + String? latestMessagePreview; + String? latestMessageTime; + + final currentUserId = + Supabase.instance.client.auth.currentUser?.id; + + if (snapshot.hasData && snapshot.data!.isNotEmpty) { + for (final msg in snapshot.data!) { + final createdAtStr = msg['created_at']?.toString(); + final senderId = msg['pengirim_admin']?.toString() ?? + msg['pengirim_guru']?.toString() ?? + msg['pengirim_murid']?.toString(); + + // Hanya hitung pesan dari orang lain + if (createdAtStr != null && senderId != currentUserId) { + final createdAt = DateTime.tryParse(createdAtStr); + if (createdAt != null) { + if (lastVisited == null || + createdAt.isAfter(lastVisited)) { + unreadCount++; + latestMessagePreview = msg['text']?.toString(); + latestMessageTime = createdAtStr; + } + } + } + } + } + + if (unreadCount == 0) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.notifications_none_rounded, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'Tidak ada notifikasi baru', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 16, + fontFamily: 'Inter', + ), + ), + ], + ), + ); + } + + // Format waktu pesan terakhir + String timeLabel = ''; + if (latestMessageTime != null) { + final dt = DateTime.tryParse(latestMessageTime)?.toLocal(); + if (dt != null) { + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 1) { + timeLabel = 'Baru saja'; + } else if (diff.inHours < 1) { + timeLabel = '${diff.inMinutes} menit lalu'; + } else if (diff.inDays < 1) { + timeLabel = '${diff.inHours} jam lalu'; + } else { + timeLabel = '${diff.inDays} hari lalu'; + } + } + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text( + 'Pesan Masuk', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + fontFamily: 'Inter', + letterSpacing: 0.5, + ), + ), + ), + Card( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: _markAsReadAndNavigate, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.groups_rounded, + color: Color(0xFF2563EB), + size: 26, + ), + ), + const SizedBox(width: 14), + // Text content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Grup Sekolah', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Color(0xFF1E293B), + fontFamily: 'Inter', + ), + ), + Text( + timeLabel, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + fontFamily: 'Inter', + ), + ), + ], + ), + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$unreadCount pesan baru', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFDC2626), + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), + ), + ), + if (latestMessagePreview != null) ...[ + const SizedBox(height: 6), + Text( + latestMessagePreview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF64748B), + fontFamily: 'Inter', + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_pesan/chat_private.dart b/lib/dashboard/a_admin/function/f_pesan/chat_private.dart new file mode 100644 index 0000000..b3a76a2 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_pesan/chat_private.dart @@ -0,0 +1,454 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/a_admin/database/db_pesan_private.dart'; +import 'package:intl/intl.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/a_admin/database/storage/image_doc.dart'; +import 'package:apk/dashboard/a_admin/function/f_pesan/doc_image_page.dart'; +import 'package:apk/dashboard/a_admin/function/f_notif/notif_reddot.dart'; + +class ChatPrivate extends StatefulWidget { + final String receiverId; + final String receiverType; // 'guru' or 'murid' + final String receiverName; + + const ChatPrivate({ + super.key, + required this.receiverId, + required this.receiverType, + required this.receiverName, + }); + + @override + State createState() => _ChatPrivateState(); +} + +class _ChatPrivateState extends State { + final TextEditingController _messageController = TextEditingController(); + final DbPesanPrivate _dbPesan = DbPesanPrivate(); + + bool _isLoading = true; + String? _errorMessage; + String? _idAdmin; + String? _currentUserId; + + Stream>>? _chatStream; + + @override + void initState() { + super.initState(); + _initChat(); + NotifRedDot.setChatPrivateSenderUnread(widget.receiverId, false); + } + + Future _initChat() async { + try { + _currentUserId = DbPesanPrivate.userId; + _idAdmin = await _dbPesan.getAdminId(); + + if (_idAdmin != null) { + if (widget.receiverType == 'guru') { + _chatStream = _dbPesan.getChatWithGuruStream(_idAdmin!, widget.receiverId); + } else if (widget.receiverType == 'murid') { + _chatStream = _dbPesan.getChatWithMuridStream(_idAdmin!, widget.receiverId); + } + } else { + _errorMessage = 'Gagal memuat profil Admin. Pastikan Anda login sebagai Admin.'; + } + } catch (e) { + _errorMessage = 'Error: $e'; + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + if (widget.receiverType == 'guru') { + await _dbPesan.sendMessageToGuru(text: 'IMAGE_URL:$url', idAdmin: _idAdmin!, idGuru: widget.receiverId); + } else if (widget.receiverType == 'murid') { + await _dbPesan.sendMessageToMurid(text: 'IMAGE_URL:$url', idAdmin: _idAdmin!, idMurid: widget.receiverId); + } + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + if (widget.receiverType == 'guru') { + await _dbPesan.sendMessageToGuru(text: 'DOC_URL:$url', idAdmin: _idAdmin!, idGuru: widget.receiverId); + } else if (widget.receiverType == 'murid') { + await _dbPesan.sendMessageToMurid(text: 'DOC_URL:$url', idAdmin: _idAdmin!, idMurid: widget.receiverId); + } + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _idAdmin == null) return; + + _messageController.clear(); + + try { + if (widget.receiverType == 'guru') { + await _dbPesan.sendMessageToGuru( + text: text, + idAdmin: _idAdmin!, + idGuru: widget.receiverId, + ); + } else if (widget.receiverType == 'murid') { + await _dbPesan.sendMessageToMurid( + text: text, + idAdmin: _idAdmin!, + idMurid: widget.receiverId, + ); + } + + // Reload stream dari database setelah mengirim pesan + if (mounted) { + setState(() { + _isLoading = true; + }); + await _initChat(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + @override + Widget build(BuildContext context) { + final appBar = AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: Text('Chat: ${widget.receiverName}'), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ); + + if (_isLoading) { + return Scaffold( + appBar: appBar, + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: appBar, + body: _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), + ), + ) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center(child: Text('Belum ada pesan')); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + final isMe = msg['pengirim_admin'] == _idAdmin; + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: Row( + children: [ + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -120), + onSelected: (value) { + if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + ], + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_pesan/doc_image_page.dart b/lib/dashboard/a_admin/function/f_pesan/doc_image_page.dart new file mode 100644 index 0000000..8ed03a3 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_pesan/doc_image_page.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class DocImagePreviewPage extends StatelessWidget { + final String imageUrl; + + const DocImagePreviewPage({super.key, required this.imageUrl}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: Center( + child: InteractiveViewer( + child: Image.network( + imageUrl, + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center(child: CircularProgressIndicator(color: Colors.white)); + }, + errorBuilder: (context, error, stackTrace) => const Icon(Icons.broken_image, color: Colors.white, size: 50), + ), + ), + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_pesan/grup_sekolah.dart b/lib/dashboard/a_admin/function/f_pesan/grup_sekolah.dart new file mode 100644 index 0000000..8bb29ee --- /dev/null +++ b/lib/dashboard/a_admin/function/f_pesan/grup_sekolah.dart @@ -0,0 +1,523 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:apk/dashboard/a_admin/database/db_grup_sekolah.dart'; +import 'package:apk/dashboard/a_admin/function/f_pesan/member_sekolah.dart'; +import 'package:apk/dashboard/a_admin/function/f_pesan/pengumuman.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/a_admin/database/storage/image_doc.dart'; +import 'package:apk/dashboard/a_admin/function/f_pesan/doc_image_page.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/a_admin/function/f_notif/notif_reddot.dart'; + +class GrupSekolah extends StatefulWidget { + const GrupSekolah({super.key}); + + @override + State createState() => _GrupSekolahState(); +} + +class _GrupSekolahState extends State { + final TextEditingController _messageController = TextEditingController(); + + bool _isLoading = true; + String? _errorMessage; + + String? _currentUserId; + String? _currentUserRole; // 'admin', 'guru', or 'murid' + + Stream>>? _chatStream; + Map _userNames = {}; // mapping ID ke Nama + + @override + void initState() { + super.initState(); + _initChat(); + NotifRedDot.setGrupSekolahUnread(false); + } + + @override + void dispose() { + _updateLastVisited(); + _messageController.dispose(); + super.dispose(); + } + + Future _updateLastVisited() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_visited_grup_sekolah', DateTime.now().toIso8601String()); + } catch (e) { + debugPrint('Error updating last visited: $e'); + } + } + + Future _initChat() async { + try { + // Fetch mapping nama semua user + _userNames = await DbGrupSekolah.fetchAllNames(); + + // Fetch current user info + final currentUserInfo = await DbGrupSekolah.getCurrentUserRoleAndId(); + + if (currentUserInfo != null) { + _currentUserId = currentUserInfo['id']; + _currentUserRole = currentUserInfo['role']; + _chatStream = DbGrupSekolah().getGrupSekolahStream(); + await _updateLastVisited(); + } else { + _errorMessage = 'Gagal memuat profil. Pastikan Anda telah login dengan benar.'; + } + } catch (e) { + _errorMessage = 'Error: $e'; + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + await DbGrupSekolah().sendMessage( + text: 'IMAGE_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + await DbGrupSekolah().sendMessage( + text: 'DOC_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _currentUserId == null || _currentUserRole == null) return; + + _messageController.clear(); + + try { + await DbGrupSekolah().sendMessage( + text: text, + senderId: _currentUserId!, + role: _currentUserRole!, + ); + + // Reload stream dari database setelah mengirim pesan + if (mounted) { + setState(() { + _isLoading = true; + }); + await _initChat(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + String _getSenderId(Map msg) { + if (msg['pengirim_admin'] != null) return msg['pengirim_admin'].toString(); + if (msg['pengirim_guru'] != null) return msg['pengirim_guru'].toString(); + if (msg['pengirim_murid'] != null) return msg['pengirim_murid'].toString(); + return ''; + } + + @override + Widget build(BuildContext context) { + final appBar = AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text('Grup Sekolah'), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + actions: [ + IconButton( + icon: const Icon(Icons.people), + tooltip: 'Anggota Grup', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MemberSekolah()), + ); + }, + ) + ], + ); + + if (_isLoading) { + return Scaffold( + appBar: appBar, + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: appBar, + body: _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), + ), + ) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center(child: Text('Belum ada pesan. Mulai obrolan!')); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + bool isMe = false; + if (_currentUserRole == 'admin') { + isMe = msg['pengirim_admin']?.toString() == _currentUserId; + } else if (_currentUserRole == 'guru') { + isMe = msg['pengirim_guru']?.toString() == _currentUserId; + } else if (_currentUserRole == 'murid') { + isMe = msg['pengirim_murid']?.toString() == _currentUserId; + } + final senderId = _getSenderId(msg); + final senderName = _userNames[senderId] ?? 'Pengguna'; + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (!isMe) + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 2), + child: Text( + senderName, + style: const TextStyle( + fontSize: 12, + color: Colors.black54, + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (msg['text']?.toString().startsWith('PENGUMUMAN_ID:') == true) + PengumumanCard(pengumumanId: msg['text'].toString().split(':')[1]) + else if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: Row( + children: [ + if (_currentUserRole == 'admin' || _currentUserRole == 'guru') + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -160), + onSelected: (value) { + if (value == 'pengumuman') { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const PengumumanPage()), + ); + } else if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'pengumuman', + child: Row( + children: [ + Icon(Icons.campaign, color: Colors.orange), + SizedBox(width: 8), + Text('Pengumuman'), + ], + ), + ), + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + ], + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_pesan/member_sekolah.dart b/lib/dashboard/a_admin/function/f_pesan/member_sekolah.dart new file mode 100644 index 0000000..379e9b0 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_pesan/member_sekolah.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/db_guru.dart'; +import 'package:apk/dashboard/a_admin/database/db_siswa.dart'; +import 'package:apk/dashboard/a_admin/database/db_kelas.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; // Jika ingin menggunakan CustomCard, opsional. + +class MemberSekolah extends StatefulWidget { + const MemberSekolah({super.key}); + + @override + State createState() => _MemberSekolahState(); +} + +class _MemberSekolahState extends State { + bool _isLoading = true; + List> _listClass = []; + List> _listGuru = []; + List> _listMurid = []; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + try { + final classRes = await ClassService.getClasses(); + final guruRes = await GuruService.getGuru(); + final muridRes = await MuridService().getMurid(); + + if (mounted) { + setState(() { + _listClass = classRes; + _listGuru = guruRes; + _listMurid = muridRes; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + final appBar = AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text('Anggota Grup Sekolah'), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ); + + if (_isLoading) { + return Scaffold( + appBar: appBar, + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: appBar, + body: _buildMemberList(), + ); + } + + Widget _buildMemberList() { + // Kita bisa mengelompokkan berdasarkan id_class + // Tambahkan 1 kelas "Tanpa Kelas" jika ada yang id_class nya null + List> allClasses = List.from(_listClass); + allClasses.add({ + 'id_tabel': null, + 'name_class': 'Tanpa Kelas (Belum Dimasukkan Kelas)' + }); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: allClasses.length, + itemBuilder: (context, index) { + final currentClass = allClasses[index]; + final classId = currentClass['id_tabel']; + final className = currentClass['name_class']; + + // Filter Guru dan Murid untuk kelas ini + final gurusInClass = _listGuru + .where((g) => g['id_class'] == classId) + .toList(); + final muridsInClass = _listMurid + .where((m) => m['id_class'] == classId) + .toList(); + + if (gurusInClass.isEmpty && muridsInClass.isEmpty && classId == null) { + return const SizedBox.shrink(); // Jangan tampilkan 'Tanpa Kelas' jika kosong + } + + return ExpansionTile( + title: Text( + className, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + children: [ + if (gurusInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Guru:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ...gurusInClass.map((g) { + final isWali = g['wali'] == true; + final name = g['name'] ?? '-'; + final initial = name.isNotEmpty ? name[0].toUpperCase() : '-'; + return ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: Text( + initial, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + ), + title: Text(name), + subtitle: Text(isWali ? 'Guru / Wali Kelas' : 'Guru'), + ); + }).toList(), + + if (muridsInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Murid:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ...muridsInClass.map((m) { + final nama = m['nama'] ?? '-'; + final initial = nama.isNotEmpty ? nama[0].toUpperCase() : '-'; + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.orange[400], + child: Text( + initial, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + ), + title: Text(nama), + subtitle: const Text('Murid'), + ); + }).toList(), + if (gurusInClass.isEmpty && muridsInClass.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text('Belum ada anggota di kelas ini.'), + ) + ], + ); + }, + ); + } +} diff --git a/lib/dashboard/a_admin/function/f_pesan/pengumuman.dart b/lib/dashboard/a_admin/function/f_pesan/pengumuman.dart new file mode 100644 index 0000000..56b45c7 --- /dev/null +++ b/lib/dashboard/a_admin/function/f_pesan/pengumuman.dart @@ -0,0 +1,562 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:apk/dashboard/a_admin/database/db_pengumuman.dart'; +import 'package:apk/dashboard/a_admin/database/storage/doc_pengumuman.dart'; +import 'package:apk/dashboard/a_admin/database/db_grup_sekolah.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/a_admin/function/f_pesan/doc_image_page.dart'; + +class PengumumanPage extends StatefulWidget { + const PengumumanPage({super.key}); + + @override + State createState() => _PengumumanPageState(); +} + +class _PengumumanPageState extends State { + final _titleController = TextEditingController(); + final _keteranganController = TextEditingController(); + DateTime? _tanggalMulai; + DateTime? _tanggalSelesai; + List _selectedFiles = []; + bool _isLoading = false; + + Future _pickFile() async { + FilePickerResult? result = await FilePicker.pickFiles( + type: FileType.any, + allowMultiple: true, + withData: true, + ); + + if (result != null) { + setState(() { + for (var file in result.files) { + if (file.bytes != null) { + _selectedFiles.add(file); + } + } + }); + } + } + + Future _selectDate(BuildContext context, bool isStart) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + if (picked != null) { + setState(() { + if (isStart) { + _tanggalMulai = picked; + } else { + _tanggalSelesai = picked; + } + }); + } + } + + Future _submit() async { + if (_titleController.text.isEmpty || _tanggalMulai == null || _tanggalSelesai == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Harap lengkapi Title, Tanggal Mulai, dan Tanggal Selesai')), + ); + return; + } + + setState(() => _isLoading = true); + + try { + // 1. Upload File if any + List uploadedUrls = []; + for (var file in _selectedFiles) { + if (file.bytes != null && file.extension != null) { + await Future.delayed(const Duration(milliseconds: 10)); // Ensure unique timestamp + final url = await DocPengumuman.uploadDoc(file.bytes!, _titleController.text, file.extension!); + if (url != null) uploadedUrls.add(url); + } + } + + // 2. Determine Guru ID if sender is guru + final roleInfo = await DbGrupSekolah.getCurrentUserRoleAndId(); + String? idGuru; + if (roleInfo != null && roleInfo['role'] == 'guru') { + idGuru = roleInfo['id']; + } + + // 3. Insert Pengumuman + String keterangan = _keteranganController.text; + if (uploadedUrls.isNotEmpty) { + keterangan += '\n\nLampiran: ${uploadedUrls.join(' ')}'; + } + + final pengumuman = await DbPengumuman.insertPengumuman( + title: _titleController.text, + tanggalMulai: DateFormat('yyyy-MM-dd').format(_tanggalMulai!), + tanggalSelesai: DateFormat('yyyy-MM-dd').format(_tanggalSelesai!), + keterangan: keterangan, + idGuru: idGuru, + ); + + final pengumumanId = pengumuman['id_tabel']; + + // 4. Send to Grup Sekolah + if (roleInfo != null) { + await DbGrupSekolah().sendMessage( + text: 'PENGUMUMAN_ID:$pengumumanId', + senderId: roleInfo['id']!, + role: roleInfo['role']!, + ); + } + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Pengumuman berhasil dikirim ke Grup Sekolah')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final appBar = AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text('Buat Pengumuman'), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ); + + if (_isLoading) { + return Scaffold( + appBar: appBar, + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + backgroundColor: Colors.white, + appBar: appBar, + body: SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Title + const Text("Title", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + TextField( + controller: _titleController, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: "Judul Pengumuman (Title)", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + + // Dates + Row( + children: [ + Expanded(child: _buildDateCard("Tanggal Mulai", _tanggalMulai, true)), + const SizedBox(width: 12), + Expanded(child: _buildDateCard("Tanggal Selesai", _tanggalSelesai, false)), + ], + ), + const SizedBox(height: 16), + + // Keterangan + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Keterangan", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 16)), + Row( + children: const [ + Icon(Icons.format_size, size: 16, color: Colors.grey), + SizedBox(width: 4), + Icon(Icons.edit, size: 16, color: Colors.grey), + ], + ) + ], + ), + const SizedBox(height: 8), + TextField( + controller: _keteranganController, + maxLines: 5, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: "Tuliskan isi pengumuman secara detail di sini...", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + + // Upload + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: _pickFile, + icon: const Icon(Icons.upload_file, color: Colors.purple), + label: const Text('Upload Doc/Image', style: TextStyle(fontFamily: 'Inter', color: Colors.purple)), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + side: const BorderSide(color: Colors.purple, width: 1), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + ), + ), + ), + if (_selectedFiles.isNotEmpty) ...[ + const SizedBox(height: 16), + const Text("Uploaded Files : ", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedFiles.map((file) { + final ext = file.extension?.toLowerCase() ?? ''; + final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isImage ? Icons.image : Icons.picture_as_pdf, + color: isImage ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + file.name, + style: const TextStyle(fontFamily: 'Inter', fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () { + setState(() { + _selectedFiles.remove(file); + }); + }, + child: const Icon(Icons.close, size: 14, color: Colors.grey), + ), + ], + ), + ); + }).toList(), + ), + ], + const SizedBox(height: 32), + + // Submit Button + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1E3A8A), // Dark blue + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Tulis Pengumuman', style: TextStyle(fontFamily: 'Inter', fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ), + ], + ), + ), + ); + } + + Widget _buildDateCard(String title, DateTime? date, bool isStart) { + return GestureDetector( + onTap: () => _selectDate(context, isStart), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ] + ), + child: Row( + children: [ + const Icon(Icons.calendar_today, color: Color(0xFF2563EB)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 12)), + const SizedBox(height: 4), + Text( + date == null ? '-' : DateFormat('dd MMM yyyy').format(date), + style: const TextStyle(fontFamily: 'Inter', fontSize: 12, color: Colors.black87), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// Widget untuk menampilkan Pengumuman di dalam obrolan Grup Sekolah +class PengumumanCard extends StatelessWidget { + final String pengumumanId; + const PengumumanCard({super.key, required this.pengumumanId}); + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: DbPengumuman.getPengumumanById(pengumumanId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + height: 50, + width: 50, + child: Center(child: CircularProgressIndicator()), + ), + ), + ); + } + if (!snapshot.hasData || snapshot.data == null) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('Pengumuman tidak ditemukan'), + ), + ); + } + + final data = snapshot.data!; + + // Memisahkan keterangan dan URL lampiran jika ada + String textKeterangan = data['keterangan'] ?? ''; + List lampiranUrls = []; + + if (textKeterangan.contains('Lampiran: ')) { + final parts = textKeterangan.split('Lampiran: '); + textKeterangan = parts[0].trim(); + final urlsString = parts.length > 1 ? parts[1].trim() : ''; + lampiranUrls = urlsString.split(RegExp(r'\s+')).where((s) => s.startsWith('http')).toList(); + } + + return Card( + color: Colors.amber[50], + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.campaign, color: Colors.amber, size: 28), + const SizedBox(width: 8), + Expanded( + child: Text( + data['title'] ?? 'Pengumuman', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ], + ), + const Divider(), + Row( + children: [ + const Icon(Icons.date_range, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + '${data['tanggal_mulai']} s/d ${data['tanggal_selesai']}', + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ], + ), + if (textKeterangan.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(textKeterangan, style: const TextStyle(fontSize: 14)), + ], + if (lampiranUrls.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: lampiranUrls.map((lampiranUrl) { + return Builder( + builder: (context) { + final uri = Uri.tryParse(lampiranUrl); + String extension = 'FILE'; + String fileName = 'Lampiran'; + if (uri != null && uri.pathSegments.isNotEmpty) { + final fullName = Uri.decodeComponent(uri.pathSegments.last); + fileName = fullName.replaceFirst(RegExp(r'^\d{13}_'), ''); + final extIndex = fullName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fullName.length - 1) { + extension = fullName.substring(extIndex + 1).toLowerCase(); + } + } + bool isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); + + if (isImage) { + return GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: lampiranUrl))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + lampiranUrl, + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ); + } else { + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(lampiranUrl); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka lampiran'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.8), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.black12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4).toUpperCase() : extension.toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '${extension.toUpperCase()} • Dokumen', + style: const TextStyle( + fontSize: 12, + color: Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + }, + ); + }).toList(), + ) + ] + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/dashboard/a_admin/function/user_admin/profil.dart b/lib/dashboard/a_admin/function/user_admin/profil.dart new file mode 100644 index 0000000..94d134a --- /dev/null +++ b/lib/dashboard/a_admin/function/user_admin/profil.dart @@ -0,0 +1,445 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/auth.dart'; +import 'package:apk/dashboard/a_admin/database/storage/image_profile.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/register/page1.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/a_admin/database/db_namasekolah.dart'; +import 'package:apk/dashboard/a_admin/function/user_admin/update_profil.dart'; + +class ProfilScreen extends StatefulWidget { + const ProfilScreen({super.key}); + + @override + State createState() => _ProfilScreenState(); +} + +class _ProfilScreenState extends State { + final AuthService _authService = AuthService(); + final ImageProfileStorage _storageService = ImageProfileStorage(); + + final nameController = TextEditingController(); + final nomorIndukController = TextEditingController(); + final emailController = TextEditingController(); + final roleController = TextEditingController(); + final namaSekolahController = TextEditingController(); + final wilayahController = TextEditingController(); + + bool isLoading = true; + String? _avatarUrl; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + Future _loadProfile() async { + final data = await _authService.getProfile(); + final userId = Supabase.instance.client.auth.currentUser?.id; + String? loadedAvatarUrl; + + if (userId != null) { + loadedAvatarUrl = await _storageService.getProfileImageUrl(userId); + } + + final schoolData = await NamaSekolahService.getSchoolDetails(); + final schoolName = schoolData?['sekolah'] ?? ''; + final schoolWilayah = schoolData?['wilayah'] ?? ''; + + if (mounted) { + setState(() { + if (data != null) { + nameController.text = data['name'] ?? ''; + nomorIndukController.text = data['nomor_induk'] ?? ''; + emailController.text = data['email'] ?? ''; + roleController.text = data['role'] ?? ''; + namaSekolahController.text = schoolName; + wilayahController.text = schoolWilayah; + } + + _avatarUrl = loadedAvatarUrl; + isLoading = false; + }); + } + } + + Future _logout() async { + await _authService.signOut(); + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const Home()), + (route) => false, + ); + } + } + + IconData _iconForLabel(String label) { + switch (label) { + case "Email": + return Icons.email_outlined; + case "Peran": + return Icons.verified_user_outlined; + case "Nama Lengkap": + return Icons.person_outline; + case "Nomor Induk": + return Icons.badge_outlined; + case "Nama Sekolah": + return Icons.account_balance_outlined; + case "Wilayah": + return Icons.location_on_outlined; + default: + return Icons.info_outline; + } + } + + String _displayValue(String value) { + return value.isEmpty ? "Belum diisi" : value; + } + + String get _nama => + nameController.text.isEmpty ? "Belum diisi" : nameController.text; + String get _nomorInduk => nomorIndukController.text.isEmpty + ? "Belum diisi" + : nomorIndukController.text; + String get _role => + roleController.text.isEmpty ? "Admin" : roleController.text; + + Widget _infoCard({ + required IconData icon, + required String title, + required String value, + bool isEmpty = false, + }) { + return CustomCard( + title: title, + subtitle: value, + icon: icon, + iconPosition: IconPosition.left, + iconColor: const Color(0xFF2563EB), + iconSize: 24, + iconContainerSize: 46, + iconDecoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(9), + ), + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + borderRadius: 0, + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + boxShadow: const [], + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF8FA0BA), + ), + subtitleStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + fontStyle: isEmpty ? FontStyle.italic : FontStyle.normal, + color: isEmpty ? const Color(0xFFC8D2E0) : const Color(0xFF172033), + ), + showIcon: true, + ); + } + + Widget field( + String label, + TextEditingController controller, { + bool readOnly = false, + TextInputType? keyboardType, + }) { + return _infoCard( + icon: _iconForLabel(label), + title: label, + value: _displayValue(controller.text), + isEmpty: controller.text.isEmpty, + ); + } + + Widget _sectionCard(List children) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8EEF6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) + const Divider( + height: 1, + thickness: 1, + color: Color(0xFFF0F4FA), + indent: 82, + endIndent: 20, + ), + ], + ], + ), + ), + ); + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 12), + child: Text( + title, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w800, + color: Color(0xFF64748B), + ), + ), + ); + } + + Widget _profileHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE8EEF6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 36, + backgroundColor: const Color(0xFF2563EB), + backgroundImage: + _avatarUrl != null ? NetworkImage(_avatarUrl!) : null, + onBackgroundImageError: _avatarUrl != null + ? (exception, stackTrace) { + if (mounted) { + setState(() => _avatarUrl = null); + } + } + : null, + child: _avatarUrl == null + ? const Icon(Icons.person, size: 36, color: Colors.white) + : null, + ), + const SizedBox(height: 16), + Text( + _nama, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 23, + fontWeight: FontWeight.w800, + color: Color(0xFF172033), + ), + ), + const SizedBox(height: 16), + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 16, + runSpacing: 10, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.badge_outlined, + size: 18, + color: Color(0xFF8FA0BA), + ), + const SizedBox(width: 6), + Text( + "No. Induk: $_nomorInduk", + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.admin_panel_settings_outlined, + size: 18, + color: Color(0xFF2563EB), + ), + const SizedBox(width: 6), + Text( + _role, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF2563EB), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + + + @override + void dispose() { + nameController.dispose(); + nomorIndukController.dispose(); + emailController.dispose(); + roleController.dispose(); + namaSekolahController.dispose(); + wilayahController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appBar = AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Profil Admin"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ); + + if (isLoading) { + return Scaffold( + appBar: appBar, + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: appBar, + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), + children: [ + _profileHeader(), + const SizedBox(height: 20), + _sectionTitle("Akun"), + _sectionCard([ + field("Email", emailController), + field("Peran", roleController), + ]), + const SizedBox(height: 18), + _sectionTitle("Identitas"), + _sectionCard([ + field("Nama Lengkap", nameController), + field("Nomor Induk", nomorIndukController), + field("Nama Sekolah", namaSekolahController), + field("Wilayah", wilayahController), + ]), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const UpdateProfilScreen()), + ); + if (result == true) { + setState(() => isLoading = true); + _loadProfile(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + child: const Text( + "Edit", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton( + onPressed: _logout, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + child: const Text( + "Logout", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/a_admin/function/user_admin/update_profil.dart b/lib/dashboard/a_admin/function/user_admin/update_profil.dart new file mode 100644 index 0000000..50eeeeb --- /dev/null +++ b/lib/dashboard/a_admin/function/user_admin/update_profil.dart @@ -0,0 +1,458 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/auth.dart'; +import 'package:apk/dashboard/a_admin/database/storage/image_profile.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/a_admin/database/db_namasekolah.dart'; + +class UpdateProfilScreen extends StatefulWidget { + const UpdateProfilScreen({super.key}); + + @override + State createState() => _UpdateProfilScreenState(); +} + +class _UpdateProfilScreenState extends State { + final AuthService _authService = AuthService(); + final ImageProfileStorage _storageService = ImageProfileStorage(); + + final nameController = TextEditingController(); + final nomorIndukController = TextEditingController(); + final emailController = TextEditingController(); + final roleController = TextEditingController(); + final namaSekolahController = TextEditingController(); + final wilayahController = TextEditingController(); + + bool isLoading = true; + bool isSaving = false; + String? _avatarUrl; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + Future _loadProfile() async { + final data = await _authService.getProfile(); + final userId = Supabase.instance.client.auth.currentUser?.id; + String? loadedAvatarUrl; + + if (userId != null) { + loadedAvatarUrl = await _storageService.getProfileImageUrl(userId); + } + + final schoolData = await NamaSekolahService.getSchoolDetails(); + final schoolName = schoolData?['sekolah'] ?? ''; + final schoolWilayah = schoolData?['wilayah'] ?? ''; + + if (mounted) { + setState(() { + if (data != null) { + nameController.text = data['name'] ?? ''; + nomorIndukController.text = data['nomor_induk'] ?? ''; + emailController.text = data['email'] ?? ''; + roleController.text = data['role'] ?? ''; + namaSekolahController.text = schoolName; + wilayahController.text = schoolWilayah; + } + + _avatarUrl = loadedAvatarUrl; + isLoading = false; + }); + } + } + + Future _updateProfile() async { + setState(() => isSaving = true); + + try { + await _authService.updateProfile( + name: nameController.text, + nomorInduk: nomorIndukController.text, + ); + await NamaSekolahService.updateNamaSekolah( + namaSekolahController.text, + wilayahController.text, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profil berhasil diperbarui')), + ); + Navigator.pop(context, true); // return true to indicate success + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal menyimpan: $e')), + ); + } + } finally { + if (mounted) { + setState(() => isSaving = false); + } + } + } + + Future _uploadImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile == null) return; + + setState(() => isLoading = true); + + try { + final bytes = await pickedFile.readAsBytes(); + final userId = Supabase.instance.client.auth.currentUser?.id; + if (userId == null) throw 'User belum login'; + + final newUrl = await _storageService.uploadProfileImage(userId, bytes); + + if (mounted) { + setState(() => _avatarUrl = newUrl); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Foto profil berhasil diperbarui')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal mengupload foto: $e')), + ); + } + } finally { + if (mounted) { + setState(() => isLoading = false); + } + } + } + + Future _deleteImage() async { + setState(() => isLoading = true); + + try { + final userId = Supabase.instance.client.auth.currentUser?.id; + if (userId == null) throw 'User belum login'; + + await _storageService.deleteProfileImage(userId); + + if (mounted) { + setState(() => _avatarUrl = null); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Foto profil berhasil dihapus')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal menghapus foto: $e')), + ); + } + } finally { + if (mounted) { + setState(() => isLoading = false); + } + } + } + + void _showEditProfileSheet() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library, color: Color(0xFF2563EB)), + title: const Text('Upload foto dari galeri'), + onTap: () { + Navigator.pop(context); + _uploadImage(); + }, + ), + if (_avatarUrl != null) + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: const Text('Hapus foto profil', style: TextStyle(color: Colors.red)), + onTap: () { + Navigator.pop(context); + _deleteImage(); + }, + ), + ], + ), + ); + }, + ); + } + + Widget _avatarEditor() { + return Column( + children: [ + CircleAvatar( + radius: 50, + backgroundColor: const Color(0xFF2563EB), + backgroundImage: _avatarUrl != null ? NetworkImage(_avatarUrl!) : null, + onBackgroundImageError: _avatarUrl != null + ? (exception, stackTrace) { + if (mounted) { + setState(() => _avatarUrl = null); + } + } + : null, + child: _avatarUrl == null + ? const Icon(Icons.person, size: 50, color: Colors.white) + : null, + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: isSaving ? null : _showEditProfileSheet, + icon: const Icon(Icons.edit, size: 16, color: Color(0xFF2563EB)), + label: const Text( + 'Edit Foto', + style: TextStyle(color: Color(0xFF2563EB), fontWeight: FontWeight.w600), + ), + ), + ], + ); + } + + Widget field( + String label, + TextEditingController controller, { + bool readOnly = false, + TextInputType? keyboardType, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + TextField( + controller: controller, + readOnly: readOnly, + keyboardType: keyboardType, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: "Masukkan $label", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: readOnly ? const Color(0xFFE8EEF6) : const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + }) { + final bool isWhite = color == Colors.white; + return Expanded( + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: isWhite ? const Color(0xFF64748B) : Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: isWhite ? const BorderSide(color: Color(0xFF94A3B8)) : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: isWhite ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + void dispose() { + nameController.dispose(); + nomorIndukController.dispose(); + emailController.dispose(); + roleController.dispose(); + namaSekolahController.dispose(); + wilayahController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final customAppBar = AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Edit Profil"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ); + + if (isLoading) { + return Scaffold( + backgroundColor: Colors.white, + appBar: customAppBar, + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + backgroundColor: Colors.white, + appBar: customAppBar, + body: Container( + color: Colors.white, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Expanded( + child: ListView( + children: [ + _avatarEditor(), + const SizedBox(height: 24), + Row( + children: const [ + Icon(Icons.person, color: Color(0xFF1E293B), size: 24), + SizedBox(width: 8), + Text( + "Akun", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("Email", emailController, readOnly: true), + field("Peran", roleController, readOnly: true), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: const [ + Icon(Icons.person, color: Color(0xFF1E293B), size: 24), + SizedBox(width: 8), + Text( + "Profil", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("Nama Lengkap", nameController), + field( + "Nomor Induk", + nomorIndukController, + keyboardType: TextInputType.number, + ), + field("Nama Sekolah", namaSekolahController), + field("Wilayah", wilayahController), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + _actionButton( + label: "Simpan", + color: const Color(0xFF2563EB), + onPressed: isSaving ? null : _updateProfile, + loading: isSaving, + ), + const SizedBox(width: 10), + _actionButton( + label: "Batal", + color: Colors.white, + onPressed: isSaving ? null : () => Navigator.pop(context), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/a_admin/widget/custom_button.dart b/lib/dashboard/a_admin/widget/custom_button.dart new file mode 100644 index 0000000..64d3a6d --- /dev/null +++ b/lib/dashboard/a_admin/widget/custom_button.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; + +enum IconPosition { + left, + right, +} + +class CustomCard extends StatelessWidget { + final String title; + final String? subtitle; + final Widget? customSubtitle; + + // SIZE + final double? width; + final double? height; + + // ACTION + final VoidCallback? onTap; + + // CARD ALIGNMENT + final AlignmentGeometry alignment; + + // STYLE + final Color backgroundColor; + final Color borderColor; + final double borderWidth; + final double borderRadius; + + // TEXT STYLE + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; + + // TEXT DEFAULT + final TextAlign titleAlign; + final TextAlign subtitleAlign; + final int? titleMaxLines; + final int? subtitleMaxLines; + final TextOverflow? titleOverflow; + final TextOverflow? subtitleOverflow; + + // ICON + final IconData? icon; + final Color iconColor; + final double iconSize; + final bool showIcon; + + // SPACING + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry margin; + + // SHADOW + final List? boxShadow; + + final Widget? customIcon; + + final IconPosition iconPosition; + final BoxDecoration? iconDecoration; + final Gradient? iconGradient; + final EdgeInsetsGeometry iconPadding; + final double iconContainerSize; + final Gradient? gradient; + + const CustomCard({ + super.key, + required this.title, + this.subtitle, + this.customSubtitle, + + this.width, + this.height, + this.onTap, + + this.alignment = Alignment.centerLeft, + + this.backgroundColor = const Color(0xFFF1F5F9), + this.borderColor = const Color(0xFFBFDBFE), + this.borderWidth = 1, + this.borderRadius = 16, + + this.titleStyle, + this.subtitleStyle, + + //TEXT DEFAULT + this.titleAlign = TextAlign.left, + this.subtitleAlign = TextAlign.left, + this.titleMaxLines, + this.subtitleMaxLines, + this.titleOverflow, + this.subtitleOverflow, + + this.icon = Icons.chevron_right, + this.iconColor = const Color(0xFF94A3B8), + this.iconSize = 24, + this.showIcon = true, + + this.padding = const EdgeInsets.all(16), + this.margin = const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + + this.boxShadow, + + this.customIcon, + + this.iconPosition = IconPosition.right, + this.iconDecoration, + this.iconGradient, + this.iconPadding = const EdgeInsets.all(8), + this.iconContainerSize = 40, + this.gradient, + }); + + Widget _buildIcon() { + if (!showIcon) return const SizedBox(); + + Widget iconWidget = customIcon ?? + (icon != null + ? Icon( + icon, + color: iconColor, + size: iconSize, + ) + : const SizedBox()); + + return Container( + width: iconContainerSize, + height: iconContainerSize, + padding: iconPadding, + decoration: iconDecoration ?? + BoxDecoration( + gradient: iconGradient, + borderRadius: BorderRadius.circular(12), + ), + child: Center(child: iconWidget), + ); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: alignment, + child: InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onTap, + child: Container( + width: width ?? double.infinity, + height: height, + margin: margin, + padding: padding, + decoration: BoxDecoration( + color: gradient == null ? backgroundColor : null, + gradient: gradient, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: borderColor, + width: borderWidth, + ), + boxShadow: boxShadow ?? + [ + BoxShadow( + // ignore: deprecated_member_use + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // ICON + if (iconPosition == IconPosition.left) ...[ + _buildIcon(), + const SizedBox(width: 8), + ], + + // TEXT + Expanded( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + + // 🔥 ini ikut textAlign + crossAxisAlignment: titleAlign == TextAlign.right + ? CrossAxisAlignment.end + : titleAlign == TextAlign.center + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + + children: [ + Text( + title, + textAlign: titleAlign, + maxLines: titleMaxLines, + overflow: titleOverflow, + style: titleStyle ?? + const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + ), + if (customSubtitle != null) ...[ + const SizedBox(height: 4), + customSubtitle!, + ] else if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + textAlign: subtitleAlign, + maxLines: subtitleMaxLines, + overflow: subtitleOverflow, + style: subtitleStyle ?? + const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFF64748B), + ), + ), + ], + ], + ), + ), + ), + if (iconPosition == IconPosition.right) ...[ + const SizedBox(width: 8), + _buildIcon(), + ], + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/widget/custom_textfield.dart b/lib/dashboard/a_admin/widget/custom_textfield.dart new file mode 100644 index 0000000..4d3053b --- /dev/null +++ b/lib/dashboard/a_admin/widget/custom_textfield.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +class CustomTextField extends StatefulWidget { + final TextEditingController controller; + final String label; + final String? hint; + final IconData? prefixIcon; + final Widget? suffixIcon; + + final bool isPassword; + final bool readOnly; + final bool enabled; + final TextInputType keyboardType; + final int maxLines; + + final String? Function(String?)? validator; + final void Function(String)? onChanged; + final VoidCallback? onTap; + + final Color borderColor; + final Color focusColor; + + const CustomTextField({ + super.key, + required this.controller, + required this.label, + this.hint, + this.prefixIcon, + this.suffixIcon, + this.isPassword = false, + this.readOnly = false, + this.enabled = true, + this.keyboardType = TextInputType.text, + this.maxLines = 1, + this.validator, + this.onChanged, + this.onTap, + this.borderColor = Colors.grey, + this.focusColor = Colors.blue, + }); + + @override + State createState() => _CustomTextFieldState(); +} + +class _CustomTextFieldState extends State { + bool obscureText = true; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: widget.controller, + obscureText: widget.isPassword ? obscureText : false, + readOnly: widget.readOnly, + enabled: widget.enabled, + keyboardType: widget.keyboardType, + maxLines: widget.isPassword ? 1 : widget.maxLines, + validator: widget.validator, + onChanged: widget.onChanged, + onTap: widget.onTap, + + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hint, + + prefixIcon: widget.prefixIcon != null + ? Icon(widget.prefixIcon) + : null, + + suffixIcon: widget.isPassword + ? IconButton( + icon: Icon( + obscureText + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + obscureText = !obscureText; + }); + }, + ) + : widget.suffixIcon, + + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: widget.borderColor), + ), + + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: widget.borderColor), + ), + + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: widget.focusColor, width: 2), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/widget/time_helper.dart b/lib/dashboard/a_admin/widget/time_helper.dart new file mode 100644 index 0000000..2473897 --- /dev/null +++ b/lib/dashboard/a_admin/widget/time_helper.dart @@ -0,0 +1,19 @@ +import 'package:intl/intl.dart'; + +class TimeHelper { + static DateTime now() { + return DateTime.now(); + } + + static String getTime() { + return DateFormat('HH:mm').format(DateTime.now()); + } + + static String getDate() { + return DateFormat('dd-MM-yyyy').format(DateTime.now()); + } + + static String getDateTime() { + return DateFormat('dd MMM yyyy, HH:mm:ss').format(DateTime.now()); + } +} \ No newline at end of file diff --git a/lib/dashboard/a_admin/widget/ui_jam.dart b/lib/dashboard/a_admin/widget/ui_jam.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/dashboard/d_c_murid/bottom_bar/nav_bar.dart b/lib/dashboard/d_c_murid/bottom_bar/nav_bar.dart new file mode 100644 index 0000000..31a5d09 --- /dev/null +++ b/lib/dashboard/d_c_murid/bottom_bar/nav_bar.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +class BottomNavO extends StatelessWidget { + final int selectedIndex; + final Function(int) onTap; + + const BottomNavO({ + super.key, + required this.selectedIndex, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 50, // Mengatur tinggi bar + child: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + selectedFontSize: 0, + unselectedFontSize: 0, + type: BottomNavigationBarType.fixed, + currentIndex: selectedIndex, + onTap: onTap, + + items: [ + BottomNavigationBarItem( + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selectedIndex == 0 + ? Colors.blue + : const Color(0xFFF1F5F9), + ), + child: Image.asset( + selectedIndex == 0 + ? 'lib/assets/icons/w_note.png' + : 'lib/assets/icons/a_note.png', + width: 24, + height: 24, + ), + ), + if (selectedIndex == 0) const SizedBox(height: 2), + if (selectedIndex == 0) + const Text( + 'Absensi', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.blue, + ), + ), + ], + ), + label: '', + ), + + BottomNavigationBarItem( + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selectedIndex == 1 + ? Colors.blue + : const Color(0xFFF1F5F9), + ), + child: Image.asset( + selectedIndex == 1 + ? 'lib/assets/icons/w_add-p.png' + : 'lib/assets/icons/a_add-p.png', + width: 24, + height: 24, + ), + ), + if (selectedIndex == 1) const SizedBox(height: 2), + if (selectedIndex == 1) + const Text( + 'Izin', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.blue, + ), + ), + ], + ), + label: '', + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/bottom_bar/nav_screen_o.dart b/lib/dashboard/d_c_murid/bottom_bar/nav_screen_o.dart new file mode 100644 index 0000000..b3fc602 --- /dev/null +++ b/lib/dashboard/d_c_murid/bottom_bar/nav_screen_o.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/d_c_murid/bottom_bar/nav_bar.dart'; +import 'package:apk/dashboard/d_c_murid/dashboard/absensi.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_izin/form_izin.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/grup_kelas_n.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/grup_sekolah_n.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/chat_private_n.dart'; +import 'package:apk/error_handler/connection.dart'; +import 'package:apk/session_timer/session_time.dart'; +import 'package:apk/closed_app/closed.dart'; + +typedef NavScreenM = MainScreenOrtu; +typedef MainScreenMurid = MainScreenOrtu; + +class MainScreenOrtu extends StatefulWidget { + const MainScreenOrtu({super.key}); + + @override + State createState() => _MainScreenOrtuState(); +} + +class _MainScreenOrtuState extends State { + int selectedIndex = 0; + + final List pages = [ + const AbsensiOrtu(), + const FormIzin(), + ]; + + @override + void initState() { + super.initState(); + NotificationFCM.init(); + _subscribeOutputAlat(); // filter ke user saat ini + GrupKelasNotification.init(); + GrupSekolahNotification.init(); + ChatPrivateNotification.init(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkInternetConnection(); + }); + } + + /// Subscribe output_alat hanya untuk user yang sedang login + Future _subscribeOutputAlat() async { + final prefs = await SharedPreferences.getInstance(); + // Prioritas: nama murid → nama guru + final nama = prefs.getString('murid_nama') ?? prefs.getString('guru_nama') ?? ''; + if (nama.isNotEmpty) { + NotificationFCM.subscribeOutputAlatForCurrentUser(nama); + } + } + + Future _checkInternetConnection() async { + bool hasConnection = await ConnectionHandler.checkConnection(); + if (!hasConnection && mounted) { + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () => _checkInternetConnection(), + ); + } + } + + void onItemTapped(int index) { + setState(() { + selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return SessionTimeManager( + child: WillPopScope( + onWillPop: () => handleDoubleTapToExit(context), + child: Scaffold( + body: IndexedStack( + index: selectedIndex, + children: pages, + ), + bottomNavigationBar: BottomNavO( + selectedIndex: selectedIndex, + onTap: onItemTapped, + ), + ), + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/dashboard/absensi.dart b/lib/dashboard/d_c_murid/dashboard/absensi.dart new file mode 100644 index 0000000..ad598f2 --- /dev/null +++ b/lib/dashboard/d_c_murid/dashboard/absensi.dart @@ -0,0 +1,432 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/dash_pesan.dart'; +import 'package:apk/dashboard/d_c_murid/function/setting/setting_data.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart'; +import 'package:apk/dashboard/d_c_murid/function/absen/absen_preview.dart'; +import 'package:apk/dashboard/d_c_murid/function/absen/absen_history.dart'; +import 'package:apk/dashboard/d_c_murid/function/p_pengumuman.dart/preview_p.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/grup_sekolah_n.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/grup_kelas_n.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/chat_private_n.dart'; +import 'package:apk/session_timer/session_time.dart'; +import 'package:apk/closed_app/closed.dart'; +import 'package:apk/error_handler/connection.dart'; + +class AbsensiOrtu extends StatefulWidget { + const AbsensiOrtu({super.key}); + + @override + State createState() => _AbsensiOrtuState(); +} + +class _AbsensiOrtuState extends State { + String studentName = 'Memuat...'; + String studentNis = '...'; + bool isLoading = false; + int _refreshKey = 0; + StreamSubscription>>? _scanSubscription; + + @override + void initState() { + super.initState(); + _fetchStudentData(); + _listenToNewScans(); + } + + @override + void dispose() { + _scanSubscription?.cancel(); + super.dispose(); + } + + final Set _knownScanIds = {}; + + /// Dengarkan scan baru dari output_alat agar tampilan Rekap Kehadiran langsung ter-update otomatis + void _listenToNewScans() { + try { + _scanSubscription = Supabase.instance.client + .from('output_alat') + .stream(primaryKey: ['id']) + .order('created_at', ascending: false) + .listen((rows) { + if (rows.isEmpty || !mounted) return; + + if (_knownScanIds.isEmpty) { + for (final r in rows) { + final id = r['id']?.toString(); + if (id != null) _knownScanIds.add(id); + } + return; + } + + bool hasNew = false; + for (final r in rows) { + final id = r['id']?.toString(); + if (id != null && !_knownScanIds.contains(id)) { + _knownScanIds.add(id); + hasNew = true; + } + } + + if (hasNew && mounted) { + setState(() { + _refreshKey++; + }); + } + }); + } catch (e) { + debugPrint('[AbsensiOrtu] Stream scan error: $e'); + } + } + + Future _fetchStudentData() async { + final hasConn = await ConnectionHandler.checkConnection(); + if (!hasConn) { + if (mounted) { + setState(() { + studentName = 'Tidak ada koneksi internet'; + studentNis = '-'; + isLoading = false; + }); + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () { + setState(() { + isLoading = true; + }); + _fetchStudentData(); + }, + ); + } + return; + } + + try { + final prefs = await SharedPreferences.getInstance(); + final nis = prefs.getString('murid_nis'); + final userId = prefs.getString('user_id_admin'); + + if (nis != null && userId != null) { + final response = await Supabase.instance.client + .from('murid') + .select() + .eq('user_id', userId) + .eq('nis', nis) + .single(); + + setState(() { + _refreshKey = _refreshKey + 1; + // Cari kolom yang mengandung 'nama' atau 'name' + String? nameValue; + for (var key in response.keys) { + if (key.toLowerCase().contains('nama') || key.toLowerCase().contains('name')) { + nameValue = response[key].toString(); + break; + } + } + studentName = nameValue ?? 'Tanpa Nama'; + studentNis = response['nis']?.toString() ?? nis; + + if (nameValue != null) { + prefs.setString('murid_nama', nameValue); + } + }); + } else { + setState(() { + studentName = 'Belum login (NIS/User ID kosong)'; + studentNis = '-'; + }); + } + } catch (e) { + setState(() { + studentName = 'Error: $e'; + studentNis = '-'; + }); + } finally { + if (mounted) { + setState(() { + isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return SessionTimeManager( + child: WillPopScope( + onWillPop: () => handleDoubleTapToExit(context), + child: Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + //toolbarHeight: 100, + titleSpacing: 20, + title: const Text('Info Siswa'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: IconButton( + icon: isLoading + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Icon(Icons.refresh, color: Colors.white), + onPressed: isLoading + ? null + : () { + setState(() { + isLoading = true; + }); + _fetchStudentData(); + }, + ), + ), + ], + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + // Menu Buttons Row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildMenuButton( + icon: Icons.assignment_turned_in_outlined, + label: 'Info Absen Siswa', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AbsenHistory(nis: studentNis), + ), + ); + }, + ), + const SizedBox(width: 32), + ValueListenableBuilder( + valueListenable: GrupSekolahNotification.hasUnread, + builder: (context, unreadSekolah, child) { + return ValueListenableBuilder( + valueListenable: GrupKelasNotification.hasUnread, + builder: (context, unreadKelas, child) { + return ValueListenableBuilder( + valueListenable: ChatPrivateNotification.hasUnread, + builder: (context, unreadPrivate, child) { + final hasAnyUnread = unreadSekolah || unreadKelas || unreadPrivate; + return _buildMenuButton( + icon: Icons.chat_bubble_outline_rounded, + label: 'Grup Chat', + showBadge: hasAnyUnread, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DashPesan(), + ), + ); + }, + ); + }, + ); + }, + ); + }, + ), + ], + ), + const SizedBox(height: 40), + // Student Info Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), +child: Row( + children: [ + // Avatar + + + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + studentName, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Text( + 'NIS:', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + studentNis, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF2563EB), + ), + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(width: 8), + + // Setting icon di kanan + IconButton( + icon: const Icon(Icons.settings_outlined, color: Color(0xFF64748B)), + tooltip: 'Pengaturan Profil', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingDataOrtu()), + ).then((_) => _fetchStudentData()); + }, + ), + ], +), + ), + const SizedBox(height: 30), + // Rekap Kehadiran + const Text( + 'Rekap Kehadiran', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 16), + AbsenPreview( + key: ValueKey('${studentNis}_$_refreshKey'), + nis: studentNis, + ), + ], + ), + ), + ), + ), + ), + ); + } + + + + Widget _buildMenuButton({ + required IconData icon, + required String label, + required VoidCallback onTap, + bool showBadge = false, + }) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: 100, // Fixed width to handle wrapping consistently + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF8C9DCD), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF8C9DCD).withOpacity(0.5), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Icon( + icon, + color: Colors.white, + size: 32, + ), + ), + if (showBadge) + Positioned( + top: -2, + right: -2, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/dashboard/leave_request.dart b/lib/dashboard/d_c_murid/dashboard/leave_request.dart new file mode 100644 index 0000000..988ad2b --- /dev/null +++ b/lib/dashboard/d_c_murid/dashboard/leave_request.dart @@ -0,0 +1,578 @@ +// Removed dart:io +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_izin.dart'; +import 'package:apk/dashboard/d_c_murid/bottom_bar/nav_screen_o.dart'; +import 'package:apk/session_timer/session_time.dart'; +import 'package:apk/closed_app/closed.dart'; +import 'package:apk/error_handler/connection.dart'; + +class LeaveRequest extends StatefulWidget { + const LeaveRequest({super.key}); + + @override + State createState() => _LeaveRequestState(); +} + +class _LeaveRequestState extends State { + final TextEditingController _keteranganController = TextEditingController(); + + DateTime? _startDate; + DateTime? _endDate; + PlatformFile? _selectedFile; + String _ketOpsi = 'izin'; // 'izin' or 'sakit' + + bool _isLoadingData = true; + bool _isSubmitting = false; + + String? _userIdAdmin; + String? _studentNis; + String? _idMurid; + String? _idGuru; + String? _idClass; + String _studentName = ''; + + @override + void initState() { + super.initState(); + _fetchUserData(); + } + + Future _fetchUserData() async { + final hasConn = await ConnectionHandler.checkConnection(); + if (!hasConn) { + if (mounted) { + setState(() => _isLoadingData = false); + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () { + setState(() => _isLoadingData = true); + _fetchUserData(); + }, + ); + } + return; + } + + try { + final prefs = await SharedPreferences.getInstance(); + _studentNis = prefs.getString('murid_nis'); + _userIdAdmin = prefs.getString('user_id_admin'); + + if (_studentNis != null && _userIdAdmin != null) { + final response = await Supabase.instance.client + .from('murid') + .select() + .eq('user_id', _userIdAdmin!) + .eq('nis', _studentNis!) + .single(); + + String? nameValue; + for (var key in response.keys) { + if (key.toLowerCase().contains('nama') || key.toLowerCase().contains('name')) { + nameValue = response[key].toString(); + break; + } + } + final resolvedStudentName = nameValue ?? 'Tanpa Nama'; + final resolvedIdMurid = response['id_tabel']?.toString(); + final resolvedIdClass = response['id_class']?.toString(); + + String? resolvedIdGuru; + + debugPrint("DIAGNOSTICS: Murid Row fetched from Supabase: $response"); + + // 1. Coba cari Wali Kelas dari tabel class_name + if (resolvedIdClass != null) { + try { + final classRes = await Supabase.instance.client + .from('class_name') + .select('id_guru') + .eq('id_tabel', resolvedIdClass) + .maybeSingle(); + debugPrint("DIAGNOSTICS: class_name search result for class ID $resolvedIdClass: $classRes"); + if (classRes != null && classRes['id_guru'] != null) { + resolvedIdGuru = classRes['id_guru']?.toString(); + } + } catch (err) { + debugPrint("Error looking up Wali Kelas from class_name: $err"); + } + } + + // 2. Fallback: Coba cari dari tabel guru di mana id_class = resolvedIdClass + if (resolvedIdGuru == null && resolvedIdClass != null) { + try { + final guruRes = await Supabase.instance.client + .from('guru') + .select('id_tabel') + .eq('id_class', resolvedIdClass) + .eq('wali', true) + .maybeSingle(); + debugPrint("DIAGNOSTICS: guru search result for class ID $resolvedIdClass: $guruRes"); + if (guruRes != null) { + resolvedIdGuru = guruRes['id_tabel']?.toString(); + } + } catch (err) { + debugPrint("Error looking up Wali Kelas from guru table: $err"); + } + } + + if (resolvedIdGuru == null) { + debugPrint("DIAGNOSTICS WARNING: Wali kelas is still null. Murid ID: $resolvedIdMurid, Class ID: $resolvedIdClass"); + } + + setState(() { + _studentName = resolvedStudentName; + _idMurid = resolvedIdMurid; + _idGuru = resolvedIdGuru; + _idClass = resolvedIdClass; + _isLoadingData = false; + }); + } else { + setState(() { + _studentName = 'Belum login (NIS tidak ditemukan)'; + _isLoadingData = false; + }); + } + } catch (e) { + debugPrint("DIAGNOSTICS ERROR: _fetchUserData failed with: $e"); + setState(() { + _studentName = 'Error memuat data profil: $e'; + _isLoadingData = false; + }); + } + } + + Future _pickDate(BuildContext context, bool isStart) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now().subtract(const Duration(days: 30)), + lastDate: DateTime.now().add(const Duration(days: 365)), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF2563EB), + ), + ), + child: child!, + ); + }, + ); + + if (picked != null) { + setState(() { + if (isStart) { + _startDate = picked; + // Validasi jika tanggal akhir lebih kecil dari tanggal mulai + if (_endDate != null && _endDate!.isBefore(_startDate!)) { + _endDate = null; + } + } else { + _endDate = picked; + } + }); + } + } + + Future _pickFile() async { + FilePickerResult? result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'png', 'jpeg', 'pdf'], + withData: true, // Sangat penting untuk support Web + ); + + if (result != null && result.files.isNotEmpty) { + setState(() { + _selectedFile = result.files.first; + }); + } + } + + Future _submitData() async { + final hasConn = await ConnectionHandler.checkConnection(); + if (!hasConn) { + if (mounted) { + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () { + // Can be retried by clicking submit again + }, + ); + } + return; + } + + if (_idMurid == null || _userIdAdmin == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal: Data profil tidak lengkap! Coba muat ulang aplikasi.'))); + return; + } + + if (_startDate == null || _endDate == null || _keteranganController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Harap lengkapi isi tanggal dan keterangan izin'))); + return; + } + + if (_selectedFile == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Harap unggah foto/dokumen surat izin'))); + return; + } + + setState(() { + _isSubmitting = true; + }); + + try { + final dbIzin = DbIzin(); + + // Pastikan file memiliki bytes (khususnya untuk Web) + if (_selectedFile!.bytes == null) { + throw Exception("Gagal membaca data file."); + } + + // Ambil Supabase Current User ID agar lolos dari RLS Policy + final currentUserId = Supabase.instance.client.auth.currentUser?.id ?? _userIdAdmin!; + + // Upload File ke Supabase Storage (doc_izin/auth_id/nis/filename) + final lampiranUrl = await dbIzin.uploadIzinFile(currentUserId, _studentNis!, _selectedFile!.bytes!, _selectedFile!.name); + + // Insert to Table leave_request + final data = { + 'tanggal_mulai': DateFormat('yyyy-MM-dd').format(_startDate!), + 'tanggal_selesai': DateFormat('yyyy-MM-dd').format(_endDate!), + 'keterangan': _keteranganController.text, + 'verif': null, // default is null (pending) + 'id_murid': _idMurid, + 'nama_murid': _studentName, + 'id_guru': _idGuru, // Memasukkan ID Guru dari tabel murid untuk verifikasi wali kelas + 'user_id': currentUserId, + 'id_kelas': _idClass, + 'ket_opsi': _ketOpsi, + }; + + final inserted = await dbIzin.submitIzin(data); + final idIzin = inserted['id_tabel']; + + // Kirim pesan privat ke Wali Kelas + if (_idGuru == null || _idGuru!.isEmpty) { + throw Exception("Wali kelas tidak ditemukan. (Debug: id_murid=$_idMurid, id_class=$_idClass, id_guru=$_idGuru)"); + } + + final startDateStr = DateFormat('dd MMM yyyy').format(_startDate!); + final endDateStr = DateFormat('dd MMM yyyy').format(_endDate!); + final jenisLabel = _ketOpsi == 'sakit' ? 'Sakit' : 'Izin'; + final textPesanPrivate = "📋 *Pengajuan Izin*\nJenis: $jenisLabel\nNama: $_studentName\nTanggal: $startDateStr s/d $endDateStr\nKeterangan: ${_keteranganController.text}\nLampiran: $lampiranUrl\nID_IZIN: $idIzin"; + + await Supabase.instance.client.from('chat_private').insert({ + 'text': textPesanPrivate, + 'pengirim_murid': _idMurid, + 'penerima_guru': _idGuru, + 'user_id': currentUserId, + 'id_izin': idIzin, + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Izin berhasil diajukan!'))); + + // Reload halaman dengan kembali ke MainScreenOrtu (beranda) + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => MainScreenOrtu()), + (route) => false, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Terjadi kesalahan: $e'))); + } + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return SessionTimeManager( + child: WillPopScope( + onWillPop: () => handleDoubleTapToExit(context), + child: Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + titleSpacing: 20, + title: const Text('Pengajuan Izin'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: _isLoadingData + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Nama Field (Read Only) + _buildLabel('Nama'), + _buildReadOnlyField(_studentName), + const SizedBox(height: 20), + + // Tanggal Mulai + _buildLabel('Tanggal Mulai'), + GestureDetector( + onTap: () => _pickDate(context, true), + child: _buildDateBox(_startDate), + ), + const SizedBox(height: 20), + + // Tanggal Selesai + _buildLabel('Tanggal Selesai'), + GestureDetector( + onTap: () => _pickDate(context, false), + child: _buildDateBox(_endDate), + ), + const SizedBox(height: 20), + + // Pilihan Izin / Sakit (ket_opsi) + _buildLabel('Pilihan Izin (Izin / Sakit)'), + Row( + children: [ + Expanded( + child: ChoiceChip( + label: const Center(child: Text('Izin')), + selected: _ketOpsi == 'izin', + selectedColor: const Color(0xFF2563EB), + checkmarkColor: Colors.white, + labelStyle: TextStyle( + fontFamily: 'Inter', + color: _ketOpsi == 'izin' ? Colors.white : Colors.black87, + fontWeight: FontWeight.bold, + ), + onSelected: (selected) { + if (selected) setState(() => _ketOpsi = 'izin'); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ChoiceChip( + label: const Center(child: Text('Sakit')), + selected: _ketOpsi == 'sakit', + selectedColor: const Color(0xFF2563EB), + checkmarkColor: Colors.white, + labelStyle: TextStyle( + fontFamily: 'Inter', + color: _ketOpsi == 'sakit' ? Colors.white : Colors.black87, + fontWeight: FontWeight.bold, + ), + onSelected: (selected) { + if (selected) setState(() => _ketOpsi = 'sakit'); + }, + ), + ), + ], + ), + const SizedBox(height: 20), + + // Keterangan Izin + _buildLabel('Keterangan Izin'), + TextFormField( + controller: _keteranganController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Jelaskan alasan izin...', + hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 14), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF2563EB)), + ), + ), + ), + const SizedBox(height: 24), + + // Upload Surat Izin + _buildLabel('Foto Surat Izin'), + GestureDetector( + onTap: _pickFile, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 30), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFE2E8F0), + style: BorderStyle.solid, + width: 1, + ), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Color(0xFFEFF6FF), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.file_upload_outlined, + color: Color(0xFF2563EB), + size: 32, + ), + ), + const SizedBox(height: 16), + Text( + _selectedFile != null + ? _selectedFile!.name + : 'Upload Foto / Dokumen', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w600, + color: _selectedFile != null ? const Color(0xFF1E293B) : const Color(0xFF475569), + ), + ), + const SizedBox(height: 4), + const Text( + 'Tap untuk memilih file', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF94A3B8), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 40), + + // Submit Button + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _isSubmitting ? null : _submitData, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Text( + 'Ajukan Izin', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ), + ); + } + + Widget _buildLabel(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF334155), + ), + ), + ); + } + + Widget _buildReadOnlyField(String text) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFF475569), + ), + ), + ); + } + + Widget _buildDateBox(DateTime? date) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.calendar_today_outlined, color: Color(0xFF94A3B8), size: 20), + const SizedBox(width: 12), + Text( + date != null ? DateFormat('MM/dd/yyyy').format(date) : 'mm/dd/yyyy', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: date != null ? const Color(0xFF1E293B) : const Color(0xFF94A3B8), + ), + ), + ], + ), + const Icon(Icons.calendar_month, color: Color(0xFF1E293B), size: 20), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/database/auth_ortu.dart b/lib/dashboard/d_c_murid/database/auth_ortu.dart new file mode 100644 index 0000000..acd7542 --- /dev/null +++ b/lib/dashboard/d_c_murid/database/auth_ortu.dart @@ -0,0 +1,51 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart'; + +class AuthOrtu { + final _supabase = Supabase.instance.client; + + Future> signInOrtu(String email, String nis) async { + try { + // 1. Resolve user_id (UUID) from the admin's email in user_admin + final adminRes = await _supabase + .from('user_admin') + .select('id') + .eq('email', email.trim()) + .maybeSingle(); + + if (adminRes == null) { + throw Exception('Email admin tidak ditemukan.'); + } + + final userId = adminRes['id'].toString(); + + // 2. Query murid with the resolved user_id and nis + final response = await _supabase + .from('murid') + .select() + .eq('user_id', userId) + .eq('nis', nis) + .single(); + + if (response['status_akun'] == false) { + throw Exception('Akun murid telah dinonaktifkan.'); + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_role', 'ortu'); + await prefs.setString('murid_nis', nis); + await prefs.setString('user_id_admin', userId); + + final nameValue = response['nama'] ?? ''; + await prefs.setString('murid_nama', nameValue); + await prefs.setString('murid_id_tabel', response['id_tabel'].toString()); + return response; + } catch (e) { + if (e.toString().contains('dinonaktifkan')) { + rethrow; + } + throw Exception('Login gagal: Periksa Email Admin dan NIS'); + } + } +} diff --git a/lib/dashboard/d_c_murid/database/db_absensi.dart b/lib/dashboard/d_c_murid/database/db_absensi.dart new file mode 100644 index 0000000..d460c6c --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_absensi.dart @@ -0,0 +1,264 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/intl.dart'; + +import 'holiday_service.dart'; + +class DbAbsensi { + final SupabaseClient _supabase = Supabase.instance.client; + + Future>> getAbsensi(String nisOrId) async { + try { + String idMurid = nisOrId; + String? studentNis; + String? userId; + String? studentName; + + final isNumeric = RegExp(r'^\d+$').hasMatch(nisOrId); + if (isNumeric) { + final res = await _supabase + .from('murid') + .select('id_tabel, user_id, nis, nama') + .eq('nis', nisOrId) + .maybeSingle(); + if (res != null) { + idMurid = res['id_tabel'].toString(); + userId = res['user_id']?.toString(); + studentNis = res['nis']?.toString(); + studentName = res['nama']?.toString(); + } + } else { + final res = await _supabase + .from('murid') + .select('user_id, nis, nama') + .eq('id_tabel', nisOrId) + .maybeSingle(); + if (res != null) { + userId = res['user_id']?.toString(); + studentNis = res['nis']?.toString(); + studentName = res['nama']?.toString(); + } + } + + // Query absen tanpa join access_bridge (tidak dipakai lagi) + final response = await _supabase + .from('absen') + .select('*, leave_request:id_izin(*), output_alat:id_output_alat(*), murid:id_murid(*), pengumuman:id_pengumuman(*)') + .eq('id_murid', idMurid) + .order('date', ascending: false); + + // Fetch approved leave requests + List> leaveResponse = []; + if (idMurid.isNotEmpty) { + try { + final res = await _supabase + .from('leave_request') + .select('*') + .eq('id_murid', idMurid) + .eq('verif', true); + leaveResponse = List>.from(res); + } catch (_) {} + } + + final Map> consolidatedLeave = {}; + for (var req in leaveResponse) { + final startStr = req['tanggal_mulai']?.toString(); + final endStr = req['tanggal_selesai']?.toString(); + if (startStr == null || endStr == null) continue; + try { + final start = DateTime.parse(startStr); + final end = DateTime.parse(endStr); + for (int d = 0; d <= end.difference(start).inDays; d++) { + final currentDate = start.add(Duration(days: d)); + final dateKey = DateFormat('yyyy-MM-dd').format(currentDate); + consolidatedLeave[dateKey] = { + 'id_tabel': req['id_tabel']?.toString(), + 'ket_opsi': req['ket_opsi']?.toString() ?? 'izin', + }; + } + } catch (_) {} + } + + final Map studentMuridMap = { + 'id_tabel': idMurid, + 'user_id': userId, + 'nis': studentNis, + 'nama': studentName, + }; + + return _mergeAbsenData(response, idMurid, studentMuridMap, consolidatedLeave); + } catch (e) { + throw Exception('Gagal memuat data absensi: $e'); + } + } + + List> _mergeAbsenData( + List absenResponse, + String idMurid, + Map studentMuridMap, + Map> consolidatedLeave) { + + final List> mergedList = []; + final Set processedDates = {}; + + for (var item in absenResponse) { + final row = Map.from(item); + final dateKey = row['date']?.toString(); + + if (dateKey != null) { + processedDates.add(dateKey); + + // Ambil jam masuk dari output_alat.created_at + final outputAlat = row['output_alat']; + if (outputAlat != null && outputAlat['created_at'] != null) { + final formattedMasuk = _formatTime(outputAlat['created_at']); + if (formattedMasuk != null) { + row['masuk'] = formattedMasuk; + } + } + + // Inject leave_request jika belum ada dari FK + if (row['leave_request'] == null && consolidatedLeave.containsKey(dateKey)) { + final leave = consolidatedLeave[dateKey]!; + row['leave_request'] = { + 'id_tabel': leave['id_tabel'], + 'ket_opsi': leave['ket_opsi'], + 'verif': true, + }; + } + } + mergedList.add(row); + } + + // Tambahkan record izin yang tidak punya row absen + consolidatedLeave.forEach((dateKey, leave) { + if (!processedDates.contains(dateKey)) { + processedDates.add(dateKey); + mergedList.add({ + 'date': dateKey, + 'id_murid': idMurid, + 'masuk': null, + 'keluar': null, + 'output_alat': null, + 'murid': studentMuridMap, + 'leave_request': { + 'id_tabel': leave['id_tabel'], + 'ket_opsi': leave['ket_opsi'], + 'verif': true, + }, + 'pengumuman': null, + }); + } + }); + + mergedList.sort((a, b) { + final dateA = a['date']?.toString() ?? ''; + final dateB = b['date']?.toString() ?? ''; + return dateB.compareTo(dateA); + }); + + return mergedList; + } + + static String? _formatTime(dynamic timeVal) { + if (timeVal == null) return null; + final str = timeVal.toString().trim(); + if (str.isEmpty) return null; + + final parsed = DateTime.tryParse(str); + if (parsed != null) { + return DateFormat('HH:mm').format(parsed.toLocal()); + } + + final parts = str.split(':'); + if (parts.length >= 2) { + var hour = parts[0]; + if (hour.contains('T')) hour = hour.split('T').last; + hour = hour.padLeft(2, '0'); + var minute = parts[1]; + if (minute.length > 2) minute = minute.substring(0, 2); + minute = minute.padLeft(2, '0'); + return '$hour:$minute'; + } + return str; + } + + static String getStatus(Map row) { + // 0. Manual override (prioritas tertinggi) + if (row['Null_data']?.toString() == 'Belum Absen') return 'Belum Absen'; + if (row['ket_sakit']?.toString().startsWith('MANUAL_') ?? false) return 'Sakit'; + if (row['ket_izin']?.toString().startsWith('MANUAL_') ?? false) return 'Izin'; + if (row['ket_libur']?.toString().startsWith('MANUAL_') ?? false) return 'Libur'; + if (row['ket_alpha']?.toString().startsWith('MANUAL_') ?? false) return 'Alpha'; + if (row['ket_hadir']?.toString().startsWith('MANUAL_') ?? false) { + final kh = row['ket_hadir'].toString(); + if (kh.toLowerCase().contains('terlambat')) return 'Terlambat'; + return 'Hadir'; + } + + // 1. Izin dan Sakit dari leave_request yang terverifikasi + if (row['leave_request'] != null && row['leave_request']['verif'] == true) { + final ketOpsi = (row['leave_request']['ket_opsi'] ?? '').toString().toLowerCase(); + if (ketOpsi == 'sakit') return 'Sakit'; + if (ketOpsi == 'izin') return 'Izin'; + final ket = (row['leave_request']['keterangan'] ?? '').toString().toLowerCase(); + if (ket.contains('sakit')) return 'Sakit'; + return 'Izin'; + } + + if (row['ket_sakit'] != null && row['ket_sakit'].toString().isNotEmpty) return 'Sakit'; + if (row['ket_izin'] != null && row['ket_izin'].toString().isNotEmpty) return 'Izin'; + + // 2. Libur dari hari Minggu / pengumuman / tabel absen + final dateStr = row['date']?.toString() ?? ''; + bool isSundayOrHoliday = false; + if (dateStr.isNotEmpty) { + try { + final date = DateTime.parse(dateStr); + if (date.weekday == DateTime.sunday || HolidayService.isHoliday(date)) { + isSundayOrHoliday = true; + } + } catch (_) {} + } + + bool isPengumumanLibur = false; + if (row['pengumuman'] != null) { + final title = (row['pengumuman']['title'] ?? '').toString().toLowerCase(); + final keterangan = (row['pengumuman']['keterangan'] ?? '').toString().toLowerCase(); + if (title.contains('libur') || keterangan.contains('libur')) { + isPengumumanLibur = true; + } + } + + if (isSundayOrHoliday || isPengumumanLibur) return 'Libur'; + if (row['ket_libur'] != null && row['ket_libur'].toString().isNotEmpty) return 'Libur'; + + // 3. Hadir / Terlambat dari output_alat.created_at + final outputAlat = row['output_alat']; + if (outputAlat != null && outputAlat['created_at'] != null) { + final formattedMasuk = _formatTime(outputAlat['created_at']) ?? '07:00'; + if (formattedMasuk.trim().isNotEmpty) { + final parts = formattedMasuk.split(':'); + if (parts.length >= 2) { + final hour = int.tryParse(parts[0]) ?? 0; + final minute = int.tryParse(parts[1]) ?? 0; + if (hour > 7 || (hour == 7 && minute > 0)) return 'Terlambat'; + } + return 'Hadir'; + } + } + + // Fallback dari kolom absen langsung + if (row['ket_hadir'] != null && row['ket_hadir'].toString().isNotEmpty) { + final kh = row['ket_hadir'].toString(); + if (kh.toLowerCase().contains('terlambat')) return 'Terlambat'; + return 'Hadir'; + } + if (row['terlambat'] != null && row['terlambat'].toString().isNotEmpty) return 'Terlambat'; + if (row['masuk'] != null && row['masuk'].toString().isNotEmpty) return 'Hadir'; + + // 4. Alpha + if (row['ket_alpha'] != null && row['ket_alpha'].toString().isNotEmpty) return 'Alpha'; + + return 'Belum Absen'; + } +} diff --git a/lib/dashboard/d_c_murid/database/db_alat.dart b/lib/dashboard/d_c_murid/database/db_alat.dart new file mode 100644 index 0000000..031764e --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_alat.dart @@ -0,0 +1,6 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/intl.dart'; + +class DbAlat { + final SupabaseClient _supabase = Supabase.instance.client; +} diff --git a/lib/dashboard/d_c_murid/database/db_grup_kelas.dart b/lib/dashboard/d_c_murid/database/db_grup_kelas.dart new file mode 100644 index 0000000..3a1f76d --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_grup_kelas.dart @@ -0,0 +1,184 @@ +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/doc_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbGrupKelas { + static final supabase = Supabase.instance.client; + + static Future getUserIdAsync() async { + final prefs = await SharedPreferences.getInstance(); + final ortuId = prefs.getString('user_id_admin'); + if (ortuId != null) return ortuId; + + final id = supabase.auth.currentUser?.id; + if (id != null) return id; + + throw Exception('User belum login'); + } + + // Determine current user's role and corresponding table ID + static Future?> getCurrentUserRoleAndId() async { + try { + final prefs = await SharedPreferences.getInstance(); + final role = prefs.getString('user_role'); + + // 1. Cek Ortu/Murid (menggunakan SharedPreferences) + if (role == 'ortu' || role == 'murid') { + final nis = prefs.getString('murid_nis'); + final adminId = prefs.getString('user_id_admin'); + if (nis != null && adminId != null) { + final muridRes = await supabase + .from('murid') + .select('id_tabel, id_class, status_akun') + .eq('user_id', adminId) + .eq('nis', nis) + .maybeSingle(); + if (muridRes != null && muridRes['status_akun'] != false) { + return {'role': 'murid', 'id': muridRes['id_tabel'].toString(), 'id_class': muridRes['id_class']?.toString() ?? ''}; + } + } + return null; + } + + // 2. Cek Guru (menggunakan SharedPreferences) + if (role == 'guru') { + final nik = prefs.getString('guru_nik'); + final adminId = prefs.getString('user_id_admin'); + if (nik != null && adminId != null) { + final guruRes = await supabase + .from('guru') + .select('id_tabel, id_class, status_akun') + .eq('user_id', adminId) + .eq('nik', nik) + .maybeSingle(); + if (guruRes != null && guruRes['status_akun'] != false) { + String classId = guruRes['id_class']?.toString() ?? ''; + if (classId.isEmpty) { + // Fallback: look up class from class_name table where this teacher is Wali Kelas + final classRes = await supabase + .from('class_name') + .select('id_tabel') + .eq('id_guru', guruRes['id_tabel']) + .maybeSingle(); + if (classRes != null) { + classId = classRes['id_tabel']?.toString() ?? ''; + } + } + return {'role': 'guru', 'id': guruRes['id_tabel'].toString(), 'id_class': classId}; + } + } + return null; + } + + // 3. Cek Admin (menggunakan Supabase Auth) + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + final adminRes = await supabase + .from('user_admin') + .select('id') + .eq('id', userId) + .maybeSingle(); + if (adminRes != null) { + return {'role': 'admin', 'id': adminRes['id'].toString()}; + } + + } catch (e) { + if (kDebugMode) print('Check Role Error: $e'); + } + return null; + } + + // Ambil semua nama (Admin, Guru, Murid) untuk ditampilkan di chat + static Future> fetchAllNames() async { + final Map namesMap = {}; + try { + // Fetch Admin names + final admins = await supabase.from('user_admin').select('id, name'); + for (var a in admins) { + if (a['id'] != null && a['name'] != null) { + namesMap[a['id'].toString()] = a['name'].toString(); + } + } + + // Fetch Guru names + final gurus = await supabase + .from('guru') + .select('id_tabel, name') + .or('status_akun.is.null,status_akun.eq.true'); + for (var g in gurus) { + if (g['id_tabel'] != null && g['name'] != null) { + namesMap[g['id_tabel'].toString()] = g['name'].toString(); + } + } + + // Fetch Murid names + // Fetch Murid names + final murids = await supabase + .from('murid') + .select('id_tabel, nama') + .or('status_akun.is.null,status_akun.eq.true'); + for (var m in murids) { + if (m['id_tabel'] != null && m['nama'] != null) { + namesMap[m['id_tabel'].toString()] = m['nama'].toString(); + } + } + } catch (e) { + if (kDebugMode) print('Fetch Names Error: $e'); + } + return namesMap; + } + + Stream>> getGrupKelasStream(String idClass) { + deleteExpiredMessages(); + return supabase + .from('grup_kelas') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((msg) => msg['id_class']?.toString() == idClass) + .toList()); + } + + // Kirim Pesan + Future sendMessage({ + required String text, + required String senderId, + required String role, + required String idClass, + }) async { + try { + await deleteExpiredMessages(); + final userId = await getUserIdAsync(); + final Map insertData = { + 'text': text, + 'user_id': userId, + 'id_class': idClass, + }; + + if (role == 'admin') { + insertData['pengirim_admin'] = senderId; + } else if (role == 'guru') { + insertData['pengirim_guru'] = senderId; + } else if (role == 'murid') { + insertData['pengirim_murid'] = senderId; + } + + await supabase.from('grup_kelas').insert(insertData); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } + + // Hapus pesan grup kelas yang lebih lama dari 24 jam + static Future deleteExpiredMessages() async { + try { + final cutOff = DateTime.now().subtract(const Duration(hours: 24)).toUtc().toIso8601String(); + await supabase.from('grup_kelas').delete().lt('created_at', cutOff); + } catch (e) { + if (kDebugMode) print('Clean Group Class Messages Error: $e'); + } + } +} diff --git a/lib/dashboard/d_c_murid/database/db_grup_sekolah.dart b/lib/dashboard/d_c_murid/database/db_grup_sekolah.dart new file mode 100644 index 0000000..46960e5 --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_grup_sekolah.dart @@ -0,0 +1,167 @@ +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/doc_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbGrupSekolah { + static final supabase = Supabase.instance.client; + + static Future getUserIdAsync() async { + final prefs = await SharedPreferences.getInstance(); + final ortuId = prefs.getString('user_id_admin'); + if (ortuId != null) return ortuId; + + final id = supabase.auth.currentUser?.id; + if (id != null) return id; + + throw Exception('User belum login'); + } + + // Determine current user's role and corresponding table ID + static Future?> getCurrentUserRoleAndId() async { + try { + final prefs = await SharedPreferences.getInstance(); + final role = prefs.getString('user_role'); + + // 1. Cek Ortu/Murid (menggunakan SharedPreferences) + if (role == 'ortu' || role == 'murid') { + final nis = prefs.getString('murid_nis'); + final adminId = prefs.getString('user_id_admin'); + if (nis != null && adminId != null) { + final muridRes = await supabase + .from('murid') + .select('id_tabel, status_akun') + .eq('user_id', adminId) + .eq('nis', nis) + .maybeSingle(); + if (muridRes != null && muridRes['status_akun'] != false) { + return {'role': 'murid', 'id': muridRes['id_tabel'].toString()}; + } + } + return null; + } + + // 2. Cek Guru (menggunakan SharedPreferences) + if (role == 'guru') { + final nik = prefs.getString('guru_nik'); + final adminId = prefs.getString('user_id_admin'); + if (nik != null && adminId != null) { + final guruRes = await supabase + .from('guru') + .select('id_tabel, status_akun') + .eq('user_id', adminId) + .eq('nik', nik) + .maybeSingle(); + if (guruRes != null && guruRes['status_akun'] != false) { + return {'role': 'guru', 'id': guruRes['id_tabel'].toString()}; + } + } + return null; + } + + // 3. Cek Admin (menggunakan Supabase Auth) + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + final adminRes = await supabase + .from('user_admin') + .select('id') + .eq('id', userId) + .maybeSingle(); + if (adminRes != null) { + return {'role': 'admin', 'id': adminRes['id'].toString()}; + } + + } catch (e) { + if (kDebugMode) print('Check Role Error: $e'); + } + return null; + } + + // Ambil semua nama (Admin, Guru, Murid) untuk ditampilkan di chat + static Future> fetchAllNames() async { + final Map namesMap = {}; + try { + // Fetch Admin names + final admins = await supabase.from('user_admin').select('id, name'); + for (var a in admins) { + if (a['id'] != null && a['name'] != null) { + namesMap[a['id'].toString()] = a['name'].toString(); + } + } + + // Fetch Guru names + final gurus = await supabase + .from('guru') + .select('id_tabel, name') + .or('status_akun.is.null,status_akun.eq.true'); + for (var g in gurus) { + if (g['id_tabel'] != null && g['name'] != null) { + namesMap[g['id_tabel'].toString()] = g['name'].toString(); + } + } + + // Fetch Murid names + final murids = await supabase + .from('murid') + .select('id_tabel, nama') + .or('status_akun.is.null,status_akun.eq.true'); + for (var m in murids) { + if (m['id_tabel'] != null && m['nama'] != null) { + namesMap[m['id_tabel'].toString()] = m['nama'].toString(); + } + } + } catch (e) { + if (kDebugMode) print('Fetch Names Error: $e'); + } + return namesMap; + } + + // Stream chat global + Stream>> getGrupSekolahStream() { + deleteExpiredMessages(); + return supabase + .from('grup_sekolah') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true); + } + + // Kirim Pesan + Future sendMessage({ + required String text, + required String senderId, + required String role, + }) async { + try { + await deleteExpiredMessages(); + final userId = await getUserIdAsync(); + final Map insertData = { + 'text': text, + 'user_id': userId, + }; + + if (role == 'admin') { + insertData['pengirim_admin'] = senderId; + } else if (role == 'guru') { + insertData['pengirim_guru'] = senderId; + } else if (role == 'murid') { + insertData['pengirim_murid'] = senderId; + } + + await supabase.from('grup_sekolah').insert(insertData); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } + + // Hapus pesan grup sekolah yang lebih lama dari 24 jam + static Future deleteExpiredMessages() async { + try { + final cutOff = DateTime.now().subtract(const Duration(hours: 24)).toUtc().toIso8601String(); + await supabase.from('grup_sekolah').delete().lt('created_at', cutOff); + } catch (e) { + if (kDebugMode) print('Clean Group School Messages Error: $e'); + } + } +} diff --git a/lib/dashboard/d_c_murid/database/db_guru.dart b/lib/dashboard/d_c_murid/database/db_guru.dart new file mode 100644 index 0000000..6b5bd36 --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_guru.dart @@ -0,0 +1,199 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbGuru { + final SupabaseClient _supabase = Supabase.instance.client; + + Future>> getAllGuru() async { + try { + // Fetch all guru, possibly with their class name if you have a relationship set up + // Or just fetch the table and handle join locally if needed + final response = await _supabase + .from('guru') + .select('*, class_name!guru_id_class_fkey(*)') + .or('status_akun.is.null,status_akun.eq.true'); + + return List>.from(response); + } catch (e) { + // If there's no relationship 'class_name', fallback to normal select + try { + final fallbackResponse = await _supabase + .from('guru') + .select('*') + .or('status_akun.is.null,status_akun.eq.true'); + return List>.from(fallbackResponse); + } catch (e2) { + throw Exception('Gagal memuat data guru: $e2'); + } + } + } + + Future>> getGuruForStudentClass() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nis = prefs.getString('murid_nis'); + + if (userId == null || nis == null) return []; + + // 1. Dapatkan info murid untuk mengambil id_class murid tersebut + final muridData = await _supabase + .from('murid') + .select('id_class') + .eq('user_id', userId) + .eq('nis', nis) + .maybeSingle(); + + if (muridData == null || muridData['id_class'] == null) return []; + final String studentClassId = muridData['id_class'].toString(); + + // 2. Dapatkan Wali Kelas untuk kelas tersebut + final classData = await _supabase + .from('class_name') + .select('id_guru') + .eq('id_tabel', studentClassId) + .maybeSingle(); + + final String? waliKelasGuruId = classData?['id_guru']?.toString(); + + // 3. Dapatkan guru yang mengajar di kelas murid tersebut (guru.id_class == studentClassId) + // dan juga Wali Kelas dari kelas tersebut (guru.id_tabel == waliKelasGuruId) + var query = _supabase + .from('guru') + .select('*') + .or('status_akun.is.null,status_akun.eq.true'); + + if (waliKelasGuruId != null && waliKelasGuruId.isNotEmpty) { + query = query.or('id_class.eq.$studentClassId,id_tabel.eq.$waliKelasGuruId'); + } else { + query = query.eq('id_class', studentClassId); + } + + final response = await query; + return List>.from(response); + } catch (e) { + return []; + } + } + + Future>> getGuruForLoggedGuruClass() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nik = prefs.getString('guru_nik'); + + if (userId == null || nik == null) return []; + + // 1. Dapatkan info guru yang login + final guruData = await _supabase + .from('guru') + .select('id_tabel, id_class') + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + + if (guruData == null) return []; + + Set validClassIds = {}; + if (guruData['id_class'] != null) { + validClassIds.add(guruData['id_class'].toString()); + } + + // Ambil class_name yang terkait dengan guru ini + final classDataList = await _supabase + .from('class_name') + .select('id_tabel, id_class') + .eq('id_guru', guruData['id_tabel']); + + for (var c in classDataList) { + validClassIds.add(c['id_tabel'].toString()); + } + + if (validClassIds.isEmpty) return []; + + // 2. Ambil semua guru yang terhubung ke kelas yang sama (melalui id_class di tabel guru) + final gurusInClass = await _supabase + .from('guru') + .select('*, class_name!guru_id_class_fkey(*)') + .inFilter('id_class', validClassIds.toList()) + .or('status_akun.is.null,status_akun.eq.true'); + + return List>.from(gurusInClass); + } catch (e) { + throw Exception('Gagal memuat data guru per kelas: $e'); + } + } + Future> getLoggedInGuruInfo() async { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nik = prefs.getString('guru_nik'); + + if (userId == null || nik == null) { + throw Exception('Sesi guru tidak valid.'); + } + + final guruData = await _supabase + .from('guru') + .select() + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + + if (guruData == null) { + throw Exception('Data guru tidak ditemukan.'); + } + + // Cek apakah wali kelas + final classData = await _supabase + .from('class_name') + .select('name_class') + .eq('id_guru', guruData['id_tabel']) + .maybeSingle(); + + guruData['is_wali_kelas'] = classData != null; + + if (classData != null) { + guruData['class_name'] = classData['name_class']; + } else if (guruData['id_class'] != null) { + // Jika bukan wali kelas, ambil nama kelas berdasarkan id_class + final classDataById = await _supabase + .from('class_name') + .select('name_class') + .eq('id_tabel', guruData['id_class']) + .maybeSingle(); + guruData['class_name'] = classDataById?['name_class']; + } else { + guruData['class_name'] = null; + } + + return guruData; + } + + Future updateGuru({ + required String idTabel, + required num nik, + required String name, + String? bidang, + String? idClass, + required bool wali, + }) async { + await _supabase.from('guru').update({ + 'nik': nik, + 'name': name, + 'bidang': bidang, + 'id_class': idClass, + 'wali': wali, + }).eq('id_tabel', idTabel); + } + + Future deleteGuru(String idTabel) async { + try { + await _supabase.from('guru').update({ + 'status_akun': false, + 'id_class': null, + }).eq('id_tabel', idTabel); + } catch (e) { + throw Exception('Gagal menghapus guru: $e'); + } + } +} diff --git a/lib/dashboard/d_c_murid/database/db_izin.dart b/lib/dashboard/d_c_murid/database/db_izin.dart new file mode 100644 index 0000000..10075e7 --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_izin.dart @@ -0,0 +1,28 @@ +import 'dart:typed_data'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DbIzin { + final SupabaseClient _supabase = Supabase.instance.client; + + Future uploadIzinFile(String authId, String nis, Uint8List fileBytes, String originalFileName) async { + try { + final fileName = '${DateTime.now().millisecondsSinceEpoch}_$originalFileName'; + final filePath = '$authId/$nis/$fileName'; + + await _supabase.storage.from('doc_izin').uploadBinary(filePath, fileBytes); + return _supabase.storage.from('doc_izin').getPublicUrl(filePath); + + } catch (e) { + throw Exception('Gagal mengunggah surat izin: $e'); + } + } + + Future> submitIzin(Map data) async { + try { + final res = await _supabase.from('leave_request').insert(data).select().single(); + return res; + } catch (e) { + throw Exception('Gagal mengajukan izin: $e'); + } + } +} diff --git a/lib/dashboard/d_c_murid/database/db_kelas.dart b/lib/dashboard/d_c_murid/database/db_kelas.dart new file mode 100644 index 0000000..9bdc232 --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_kelas.dart @@ -0,0 +1,136 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbKelas { + final _supabase = Supabase.instance.client; + + Future>> getMuridForGuru() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nik = prefs.getString('guru_nik'); + + if (userId == null || nik == null) { + throw Exception('Sesi guru tidak valid.'); + } + + // 1. Dapatkan Guru dan id_class-nya jika ada + final guruData = await _supabase + .from('guru') + .select('id_tabel, id_class') + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + + if (guruData == null) { + throw Exception('Data guru tidak ditemukan.'); + } + + final guruIdTabel = guruData['id_tabel']; + final guruIdClass = guruData['id_class']; // Dari tabel guru + + // 2. Kumpulkan semua ID Kelas (UUID) yang terkait dengan guru ini + Set validClassIds = {}; + if (guruIdClass != null) { + validClassIds.add(guruIdClass.toString()); + } + + // Juga cek tabel class_name dimana id_guru = guruIdTabel + final classDataList = await _supabase + .from('class_name') + .select('id_tabel, id_class') + .eq('id_guru', guruIdTabel); + + for (var c in classDataList) { + validClassIds.add(c['id_tabel'].toString()); + } + + if (validClassIds.isEmpty) { + // Guru benar-benar tidak terhubung ke kelas manapun + return []; + } + + // 3. Kumpulkan Murid dengan berbagai kemungkinan relasi + Map> allMurid = {}; + + // Relasi A: Murid yang id_class-nya merujuk ke validClassIds + final muridListA = await _supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class') + .or('status_akun.is.null,status_akun.eq.true') + .inFilter('id_class', validClassIds.toList()); + + for (var m in muridListA) { + allMurid[m['id_tabel'].toString()] = m; + } + + // Relasi B: Membaca dari id_murid di tabel class_name (seperti instruksi Anda) + // Cari baris class_name yang id_tabel-nya ada di validClassIds DAN punya id_murid + final classMuridRows = await _supabase + .from('class_name') + .select('id_murid') + .inFilter('id_tabel', validClassIds.toList()) + .not('id_murid', 'is', null); + + List muridIdsFromClass = classMuridRows + .map((r) => r['id_murid'].toString()) + .where((id) => id != 'null') + .toList(); + + if (muridIdsFromClass.isNotEmpty) { + final muridListB = await _supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class') + .or('status_akun.is.null,status_akun.eq.true') + .inFilter('id_tabel', muridIdsFromClass); + + for (var m in muridListB) { + allMurid[m['id_tabel'].toString()] = m; + } + } + + // Relasi C: Jika class_name menggunakan 'id_class' numeric untuk mengelompokkan + // Coba cari numeric id_class dari classDataList + List numericClassIds = classDataList + .map((c) => c['id_class'] as num?) + .where((id) => id != null) + .cast() + .toList(); + + if (numericClassIds.isNotEmpty) { + final classRowsByNumeric = await _supabase + .from('class_name') + .select('id_murid') + .inFilter('id_class', numericClassIds) + .not('id_murid', 'is', null); + + List extraMuridIds = classRowsByNumeric + .map((r) => r['id_murid'].toString()) + .where((id) => id != 'null') + .toList(); + + if (extraMuridIds.isNotEmpty) { + final muridListC = await _supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class') + .or('status_akun.is.null,status_akun.eq.true') + .inFilter('id_tabel', extraMuridIds); + + for (var m in muridListC) { + allMurid[m['id_tabel'].toString()] = m; + } + } + } + + // Return hasil akhir dan urutkan berdasarkan nama + final finalResult = allMurid.values.toList(); + finalResult.sort((a, b) => (a['nama']?.toString() ?? '').compareTo(b['nama']?.toString() ?? '')); + + return finalResult; + + } catch (e) { + print('DEBUG getMuridForGuru ERROR: $e'); + throw Exception('Gagal memuat murid: $e'); + } + } +} diff --git a/lib/dashboard/d_c_murid/database/db_murid.dart b/lib/dashboard/d_c_murid/database/db_murid.dart new file mode 100644 index 0000000..b58c1d6 --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_murid.dart @@ -0,0 +1,102 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DbMurid { + final SupabaseClient _supabase = Supabase.instance.client; + + Future>> getAllMurid() async { + try { + final response = await _supabase + .from('murid') + .select('*, class_name(*)') + .or('status_akun.is.null,status_akun.eq.true'); + + return List>.from(response); + } catch (e) { + try { + final fallbackResponse = await _supabase + .from('murid') + .select('*') + .or('status_akun.is.null,status_akun.eq.true'); + return List>.from(fallbackResponse); + } catch (e2) { + throw Exception('Gagal memuat data murid: $e2'); + } + } + } + + Future addMurid({ + required String nis, + required String nama, + required String userId, + String? idClass, + String? gender, + DateTime? tanggalLahir, + String? alamat, + String? orangTua, + num? noTele, + }) async { + try { + await _supabase.from('murid').insert({ + 'nis': nis, + 'nama': nama, + 'user_id': userId, + 'id_class': idClass, + 'gender': gender, + 'tanggal_lahir': tanggalLahir?.toIso8601String().split('T').first, + 'alamat': alamat, + 'orang_tua': orangTua, + 'no_tele': noTele, + 'status_akun': true, + }); + } catch (e) { + throw Exception('Gagal menambahkan murid: $e'); + } + } + + Future deleteMurid(String idTabel) async { + try { + await _supabase.from('murid').update({ + 'status_akun': false, + 'id_class': null, + }).eq('id_tabel', idTabel); + } catch (e) { + throw Exception('Gagal menghapus murid: $e'); + } + } + + Future>> getKelas() async { + try { + final response = await _supabase.from('class_name').select('*'); + return List>.from(response); + } catch (e) { + throw Exception('Gagal memuat data kelas: $e'); + } + } + + Future updateMurid({ + required String idTabel, + required String nis, + required String nama, + String? idClass, + String? gender, + DateTime? tanggalLahir, + String? alamat, + String? orangTua, + num? noTele, + }) async { + try { + await _supabase.from('murid').update({ + 'nis': nis, + 'nama': nama, + 'id_class': idClass, + 'gender': gender, + 'tanggal_lahir': tanggalLahir?.toIso8601String().split('T').first, + 'alamat': alamat, + 'orang_tua': orangTua, + 'no_tele': noTele, + }).eq('id_tabel', idTabel); + } catch (e) { + throw Exception('Gagal mengupdate profil: $e'); + } + } +} diff --git a/lib/dashboard/d_c_murid/database/db_pengumuman.dart b/lib/dashboard/d_c_murid/database/db_pengumuman.dart new file mode 100644 index 0000000..29ba27b --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_pengumuman.dart @@ -0,0 +1,87 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbPengumuman { + static final supabase = Supabase.instance.client; + + static Future getUserIdAsync() async { + final prefs = await SharedPreferences.getInstance(); + final ortuId = prefs.getString('user_id_admin'); + if (ortuId != null) return ortuId; + + final id = supabase.auth.currentUser?.id; + if (id != null) return id; + + throw Exception('User belum login'); + } + + // Insert pengumuman baru + static Future> insertPengumuman({ + required String title, + required String tanggalMulai, + required String tanggalSelesai, + String? keterangan, + String? idGuru, + }) async { + // Bersihkan pengumuman yang sudah kadaluarsa + await deleteExpiredPengumuman(); + + final userId = await getUserIdAsync(); + + final data = { + 'user_id': userId, + 'title': title, + 'tanggal_mulai': tanggalMulai, + 'tanggal_selesai': tanggalSelesai, + if (keterangan != null && keterangan.isNotEmpty) 'keterangan': keterangan, + if (idGuru != null && idGuru.isNotEmpty) 'id_guru': idGuru, + }; + + final res = await supabase.from('pengumuman').insert(data).select().single(); + return res; + } + + // Get pengumuman berdasarkan ID + static Future?> getPengumumanById(String id) async { + try { + final res = await supabase.from('pengumuman').select().eq('id_tabel', id).maybeSingle(); + return res; + } catch (e) { + return null; + } + } + + // Hapus otomatis pengumuman yang sudah berakhir (tanggal_selesai < hari ini) + static Future deleteExpiredPengumuman() async { + try { + final now = DateTime.now(); + final todayStr = "${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}"; + + // 1. Dapatkan semua ID pengumuman yang sudah kadaluarsa + final expired = await supabase + .from('pengumuman') + .select('id_tabel') + .lt('tanggal_selesai', todayStr); + + if (expired != null && expired.isNotEmpty) { + final List expiredIds = expired + .map((e) => e['id_tabel'].toString()) + .toList(); + + // 2. Kosongkan referensi id_pengumuman di tabel absen agar tidak melanggar foreign key constraint + await supabase + .from('absen') + .update({'id_pengumuman': null}) + .inFilter('id_pengumuman', expiredIds); + + // 3. Hapus pengumuman tersebut dari tabel pengumuman + await supabase + .from('pengumuman') + .delete() + .inFilter('id_tabel', expiredIds); + } + } catch (e) { + // Silently ignore or log error + } + } +} diff --git a/lib/dashboard/d_c_murid/database/db_pesan_private.dart b/lib/dashboard/d_c_murid/database/db_pesan_private.dart new file mode 100644 index 0000000..96ce984 --- /dev/null +++ b/lib/dashboard/d_c_murid/database/db_pesan_private.dart @@ -0,0 +1,142 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbPesanPrivate { + static final supabase = Supabase.instance.client; + + static Future getUserIdAsync() async { + final prefs = await SharedPreferences.getInstance(); + final ortuId = prefs.getString('user_id_admin'); + if (ortuId != null) return ortuId; + + final id = supabase.auth.currentUser?.id; + if (id != null) return id; + + throw Exception('User belum login'); + } + + // Get Admin ID (for the current logged in user) + Future getAdminId() async { + try { + final userId = await getUserIdAsync(); + final res = await supabase + .from('user_admin') + .select('id') + .eq('id', userId) + .single(); + return res['id']?.toString(); + } catch (e) { + if (kDebugMode) print('Get Admin ID Error: $e'); + return null; + } + } + + // Stream chat between Admin and Guru + Stream>> getChatWithGuruStream(String idAdmin, String idGuru) { + deleteExpiredMessages(); + return supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_admin'] == idAdmin && e['penerima_guru'] == idGuru) || + (e['pengirim_guru'] == idGuru && e['penerima_admin'] == idAdmin)) + .map((e) => e as Map) + .toList()); + } + + // Stream chat between Admin and Murid + Stream>> getChatWithMuridStream(String idAdmin, String idMurid) { + deleteExpiredMessages(); + return supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_admin'] == idAdmin && e['penerima_murid'] == idMurid) || + (e['pengirim_murid'] == idMurid && e['penerima_admin'] == idAdmin)) + .map((e) => e as Map) + .toList()); + } + + // Send Message to Guru + Future sendMessageToGuru({ + required String text, + required String idAdmin, + required String idGuru, + }) async { + try { + await deleteExpiredMessages(); + final userId = await getUserIdAsync(); + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_admin': idAdmin, + 'penerima_guru': idGuru, + 'user_id': userId, + }); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } + + // Send Message to Murid + Future sendMessageToMurid({ + required String text, + required String idAdmin, + required String idMurid, + }) async { + try { + await deleteExpiredMessages(); + final userId = await getUserIdAsync(); + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_admin': idAdmin, + 'penerima_murid': idMurid, + 'user_id': userId, + }); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } + + // Stream chat between Admin and Murid (For Murid App) + Stream>> getChatWithAdminStream(String idMurid, String idAdmin) { + deleteExpiredMessages(); + return supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_admin'] == idAdmin && e['penerima_murid'] == idMurid) || + (e['pengirim_murid'] == idMurid && e['penerima_admin'] == idAdmin)) + .map((e) => e as Map) + .toList()); + } + + // Send message general (For Murid App sending to admin) + Future sendMessage(Map data) async { + try { + await deleteExpiredMessages(); + await supabase.from('chat_private').insert(data); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } + + // Hapus pesan privat yang lebih lama dari 24 jam + static Future deleteExpiredMessages() async { + try { + final cutOff = DateTime.now().subtract(const Duration(hours: 24)).toUtc().toIso8601String(); + await supabase.from('chat_private').delete().lt('created_at', cutOff); + } catch (e) { + if (kDebugMode) print('Clean Private Messages Error: $e'); + } + } +} diff --git a/lib/dashboard/d_c_murid/database/holiday_service.dart b/lib/dashboard/d_c_murid/database/holiday_service.dart new file mode 100644 index 0000000..f089dab --- /dev/null +++ b/lib/dashboard/d_c_murid/database/holiday_service.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class HolidayService { + static const String _cacheKey = 'holiday_cache_2026'; + + // Set of formatted dates "yyyy-MM-dd" + static Set _holidays = {}; + + // Fallback major 2026 Indonesian holidays + static const List _fallbackHolidays = [ + '2026-01-01', // Tahun Baru Masehi + '2026-01-27', // Isra Mikraj Nabi Muhammad SAW + '2026-02-17', // Tahun Baru Imlek + '2026-03-20', // Hari Suci Nyepi + '2026-04-03', // Wafat Yesus Kristus + '2026-04-05', // Kebangkitan Yesus Kristus + '2026-05-01', // Hari Buruh Internasional + '2026-05-14', // Kenaikan Yesus Kristus + '2026-05-27', // Hari Raya Idul Adha + '2026-06-01', // Hari Lahir Pancasila + '2026-06-16', // Tahun Baru Islam + '2026-08-17', // Hari Kemerdekaan RI + '2026-08-25', // Maulid Nabi Muhammad SAW + '2026-12-25', // Hari Raya Natal + ]; + + /// Initialize the holiday service. Loads cached values if available, + /// otherwise uses fallback values, and tries to fetch the latest values from API. + static Future init() async { + // 1. Try to load from local cache first + try { + final prefs = await SharedPreferences.getInstance(); + final cachedJson = prefs.getString(_cacheKey); + if (cachedJson != null) { + final List decoded = jsonDecode(cachedJson); + _holidays = decoded.cast().toSet(); + } + } catch (_) { + // Ignore cache load error + } + + // If cache is empty, load fallback initially + if (_holidays.isEmpty) { + _holidays = _fallbackHolidays.toSet(); + } + + // 2. Fetch from API with a 5-second timeout + try { + final apiUrl = dotenv.env['HOLIDAY_API_URL']; + if (apiUrl != null && apiUrl.isNotEmpty) { + final response = await http.get(Uri.parse(apiUrl)).timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + final Set fetchedHolidays = {}; + for (var item in data) { + if (item is Map && item.containsKey('date')) { + fetchedHolidays.add(item['date'].toString()); + } + } + if (fetchedHolidays.isNotEmpty) { + _holidays = fetchedHolidays; + // Save to cache + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_cacheKey, jsonEncode(_holidays.toList())); + } + } + } + } catch (_) { + // Failed to fetch or parse, fallback/cache will be used + } + } + + /// Check if a date is a public holiday + static bool isHoliday(DateTime date) { + final dateStr = DateFormat('yyyy-MM-dd').format(date); + return _holidays.contains(dateStr); + } +} diff --git a/lib/dashboard/d_c_murid/database/storage/image_doc.dart b/lib/dashboard/d_c_murid/database/storage/image_doc.dart new file mode 100644 index 0000000..0704341 --- /dev/null +++ b/lib/dashboard/d_c_murid/database/storage/image_doc.dart @@ -0,0 +1,56 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ImageDocStorage { + static final supabase = Supabase.instance.client; + + static Future uploadFile(Uint8List bytes, String fileName) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin') ?? supabase.auth.currentUser?.id; + final nip = prefs.getString('guru_nik') ?? 'admin'; + + if (userId == null) return null; + + // path: userid/nip/doc_image/fileName (di dalam bucket chat_file) + final path = '$userId/$nip/doc_image/${DateTime.now().millisecondsSinceEpoch}_$fileName'; + + await supabase.storage.from('chat_file').uploadBinary( + path, + bytes, + fileOptions: const FileOptions(upsert: true), + ); + + return supabase.storage.from('chat_file').getPublicUrl(path); + } catch (e) { + print('Upload Image/Doc Error: $e'); + return null; + } + } + + static Future uploadFileObject(File file, String fileName) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin') ?? supabase.auth.currentUser?.id; + final nip = prefs.getString('guru_nik') ?? 'admin'; + + if (userId == null) return null; + + // path: userid/nip/doc_image/fileName + final path = '$userId/$nip/doc_image/${DateTime.now().millisecondsSinceEpoch}_$fileName'; + + await supabase.storage.from('chat_file').upload( + path, + file, + fileOptions: const FileOptions(upsert: true), + ); + + return supabase.storage.from('chat_file').getPublicUrl(path); + } catch (e) { + print('Upload File Error: $e'); + return null; + } + } +} diff --git a/lib/dashboard/d_c_murid/function/FCM_absen.dart/chat_private_n.dart b/lib/dashboard/d_c_murid/function/FCM_absen.dart/chat_private_n.dart new file mode 100644 index 0000000..397c7aa --- /dev/null +++ b/lib/dashboard/d_c_murid/function/FCM_absen.dart/chat_private_n.dart @@ -0,0 +1,361 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_guru.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/chat_private.dart'; + +/// Notification handler for Chat Private (Guru ↔ Ortu/Murid). +/// Must call [ChatPrivateNotification.init()] once after login. +/// Call [ChatPrivateNotification.dispose()] on logout. +class ChatPrivateNotification { + // GlobalKey provided by the root MaterialApp (shared with other notification handlers) + static GlobalKey? navigatorKey; + + static OverlayEntry? _overlayEntry; + static Timer? _autoDismissTimer; + + static String? _currentUserId; // ID di tabel murid/guru (bukan auth.uid) + static String? _currentUserRole; // 'ortu', 'murid', or 'guru' + + // Info pengirim terakhir agar navigasi buka chat yang benar + static String? _lastSenderId; + static String? _lastSenderName; + static String? _lastSenderType; // 'guru', 'murid', or 'admin' + + // Supabase stream subscription + static StreamSubscription>>? _streamSubscription; + static DateTime? _lastSeenAt; + + static final ValueNotifier hasUnread = ValueNotifier(false); + static final ValueNotifier> unreadSenderIds = ValueNotifier>({}); + + static Future setSenderUnread(String senderId, bool isUnread) async { + final current = Set.from(unreadSenderIds.value); + if (isUnread) { + current.add(senderId); + } else { + current.remove(senderId); + } + unreadSenderIds.value = current; + hasUnread.value = current.isNotEmpty; + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList('chat_private_unread_senders', current.toList()); + await prefs.setBool('chat_private_has_unread', current.isNotEmpty); + if (!isUnread) { + await prefs.setString('chat_private_last_read_$senderId', DateTime.now().toUtc().toIso8601String()); + } + if (!isUnread) { + await prefs.setString('chat_private_last_read_$senderId', DateTime.now().toUtc().toIso8601String()); + } + } catch (e) { + debugPrint('[ChatPrivateNotification] setSenderUnread error: $e'); + } + } + + static Future setUnread(bool value) async { + if (!value) { + unreadSenderIds.value = {}; + hasUnread.value = false; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('chat_private_unread_senders'); + await prefs.setBool('chat_private_has_unread', false); + } catch (e) { + debugPrint('[ChatPrivateNotification] setUnread error: $e'); + } + } else { + hasUnread.value = true; + } + } + + /// Initialize the listener. Safe to call multiple times — will re-initialize. + static Future init({GlobalKey? key}) async { + if (key != null) navigatorKey = key; + + await dispose(); // bersihkan state sebelumnya + + try { + final prefs = await SharedPreferences.getInstance(); + final role = prefs.getString('user_role') ?? ''; + + if (role == 'ortu' || role == 'murid') { + await _initForMurid(prefs); + } else if (role == 'guru') { + await _initForGuru(); + } + + if (_currentUserId != null) { + final unreadList = prefs.getStringList('chat_private_unread_senders') ?? []; + unreadSenderIds.value = unreadList.toSet(); + hasUnread.value = unreadSenderIds.value.isNotEmpty || (prefs.getBool('chat_private_has_unread') ?? false); + + + _subscribeRealtime(); + debugPrint('[ChatPrivateNotification] initialized (role: $_currentUserRole, id: $_currentUserId, unread: ${unreadSenderIds.value})'); + } + // Admin tidak perlu notif chat private di sini + } catch (e) { + debugPrint('[ChatPrivateNotification] init error: $e'); + } + } + + // ─── Init untuk Ortu/Murid ──────────────────────────────────────────────── + + static Future _initForMurid(SharedPreferences prefs) async { + final nis = prefs.getString('murid_nis'); + final adminId = prefs.getString('user_id_admin'); + if (nis == null || adminId == null) return; + + final muridRes = await Supabase.instance.client + .from('murid') + .select('id_tabel') + .eq('user_id', adminId) + .eq('nis', nis) + .or('status_akun.is.null,status_akun.eq.true') + .maybeSingle(); + + if (muridRes == null) return; + + _currentUserId = muridRes['id_tabel'].toString(); + _currentUserRole = 'murid'; + } + + // ─── Init untuk Guru ────────────────────────────────────────────────────── + + static Future _initForGuru() async { + final guruInfo = await DbGuru().getLoggedInGuruInfo(); + _currentUserId = guruInfo['id_tabel']?.toString(); + _currentUserRole = 'guru'; + } + + // ─── Tangani Entrig event yang diteruskan dari fcm_notif.dart ────────────── + + // ─── Supabase Realtime ──────────────────────────────────────────────────── + + static void _subscribeRealtime() async { + final supabase = Supabase.instance.client; + final prefs = await SharedPreferences.getInstance(); + _lastSeenAt = DateTime.now().toUtc(); + + _streamSubscription = supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: false) + .listen( + (rows) { + // Filter: only messages where recipient matches our id + final filtered = rows.where((row) { + final recipientId = _currentUserRole == 'guru' + ? row['penerima_guru']?.toString() + : row['penerima_murid']?.toString(); + return recipientId == _currentUserId; + }).toList(); + + // Filter: only new messages (after init / lastSeenAt) + final newMsgs = filtered.where((row) { + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + return t != null && t.isAfter(_lastSeenAt!); + }).toList(); + + for (final row in filtered) { + final senderId = row['pengirim_murid']?.toString() ?? row['pengirim_guru']?.toString() ?? row['pengirim_admin']?.toString(); + if (senderId != null && senderId != _currentUserId) { + final lastReadStr = prefs.getString('chat_private_last_read_$senderId'); + final lastRead = lastReadStr != null ? DateTime.parse(lastReadStr) : DateTime.fromMillisecondsSinceEpoch(0); + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + if (t != null && t.isAfter(lastRead) && !unreadSenderIds.value.contains(senderId)) { + setSenderUnread(senderId, true); + } + } + } + + for (final row in filtered) { + final senderId = row['pengirim_murid']?.toString() ?? row['pengirim_guru']?.toString() ?? row['pengirim_admin']?.toString(); + if (senderId != null && senderId != _currentUserId) { + final lastReadStr = prefs.getString('chat_private_last_read_$senderId'); + final lastRead = lastReadStr != null ? DateTime.parse(lastReadStr) : DateTime.fromMillisecondsSinceEpoch(0); + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + if (t != null && t.isAfter(lastRead) && !unreadSenderIds.value.contains(senderId)) { + setSenderUnread(senderId, true); + } + } + } + + if (newMsgs.isEmpty) return; + + // Update lastSeenAt + for (final row in newMsgs) { + final t = DateTime.tryParse(row['created_at'].toString()); + if (t != null && t.isAfter(_lastSeenAt!)) _lastSeenAt = t; + } + + debugPrint('[ChatPrivateNotification] stream → ${newMsgs.length} pesan baru'); + _handleRealtimeInsert(newMsgs.first); + }, + onError: (e) { + debugPrint('[ChatPrivateNotification] stream error: $e'); + }, + ); + } + + static void _handleRealtimeInsert(Map row) { + // Skip if sent by ourselves + final senderMurid = row['pengirim_murid']?.toString(); + final senderGuru = row['pengirim_guru']?.toString(); + final senderAdmin = row['pengirim_admin']?.toString(); + final senderId = senderMurid ?? senderGuru ?? senderAdmin; + + if (senderId != null && senderId == _currentUserId) { + return; + } + + final text = row['text']?.toString() ?? ''; + + if (senderId != null) { + setSenderUnread(senderId, true); + } + + if (senderMurid != null) { + _fetchMuridNameAndNotify( + senderId: senderMurid, + senderType: 'murid', + text: text, + ); + } else if (senderGuru != null) { + _fetchGuruNameAndNotify( + senderId: senderGuru, + senderType: 'guru', + text: text, + ); + } else if (senderAdmin != null) { + _fetchAdminNameAndNotify( + senderId: senderAdmin, + senderType: 'admin', + text: text, + ); + } + } + + + + // ─── Helper: Fetch nama lalu tampilkan overlay ──────────────────────────── + + static Future _fetchGuruNameAndNotify({ + required String senderId, + required String senderType, + required String text, + }) async { + try { + final res = await Supabase.instance.client + .from('guru') + .select('name') + .eq('id_tabel', senderId) + .maybeSingle(); + _lastSenderId = senderId; + _lastSenderType = senderType; + _lastSenderName = res?['name']?.toString() ?? 'Guru'; + _showOverlayCard(senderName: _lastSenderName!, text: text); + } catch (_) { + _lastSenderId = senderId; + _lastSenderType = senderType; + _lastSenderName = 'Guru'; + _showOverlayCard(senderName: 'Guru', text: text); + } + } + + static Future _fetchMuridNameAndNotify({ + required String senderId, + required String senderType, + required String text, + }) async { + try { + final res = await Supabase.instance.client + .from('murid') + .select('nama') + .eq('id_tabel', senderId) + .maybeSingle(); + _lastSenderId = senderId; + _lastSenderType = senderType; + _lastSenderName = res?['nama']?.toString() ?? 'Murid'; + _showOverlayCard(senderName: _lastSenderName!, text: text); + } catch (_) { + _lastSenderId = senderId; + _lastSenderType = senderType; + _lastSenderName = 'Murid'; + _showOverlayCard(senderName: 'Murid', text: text); + } + } + + static Future _fetchAdminNameAndNotify({ + required String senderId, + required String senderType, + required String text, + }) async { + try { + final res = await Supabase.instance.client + .from('user_admin') + .select('name') + .eq('id', senderId) + .maybeSingle(); + _lastSenderId = senderId; + _lastSenderType = senderType; + _lastSenderName = res?['name']?.toString() ?? 'Admin'; + _showOverlayCard(senderName: _lastSenderName!, text: text); + } catch (_) { + _lastSenderId = senderId; + _lastSenderType = senderType; + _lastSenderName = 'Admin'; + _showOverlayCard(senderName: 'Admin', text: text); + } + } + + // ─── Dispose ────────────────────────────────────────────────────────────── + + static Future dispose() async { + _streamSubscription?.cancel(); + _streamSubscription = null; + _lastSeenAt = null; + _dismissOverlay(); + } + + // ─── Overlay UI ─────────────────────────────────────────────────────────── + + static void _showOverlayCard({ + required String senderName, + required String text, + }) { + // Disabled overlay popups as requested, keeping only red dot/badge updates + return; + } + + static void _navigateToChatPrivate() { + final senderId = _lastSenderId; + final senderName = _lastSenderName ?? ''; + final senderType = _lastSenderType ?? ''; + if (senderId == null) return; + + // Murid/Ortu membuka ChatPrivate menuju guru pengirim + navigatorKey?.currentState?.push( + MaterialPageRoute( + builder: (_) => ChatPrivate( + receiverId: senderId, + receiverType: senderType, + receiverName: senderName, + ), + ), + ); + } + + static void _dismissOverlay() { + _autoDismissTimer?.cancel(); + _autoDismissTimer = null; + if (_overlayEntry != null) { + _overlayEntry!.remove(); + _overlayEntry = null; + } + } +} diff --git a/lib/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart b/lib/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart new file mode 100644 index 0000000..e710867 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart @@ -0,0 +1,501 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:apk/firebase_options.dart'; +import 'package:apk/dashboard/d_c_murid/function/absen/absen_history.dart'; +import 'package:apk/dashboard/a_admin/function/f_notif/notif_reddot.dart'; + +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + debugPrint('[FCM Background] Message received: id=${message.messageId}, data=${message.data}'); +} + +class NotificationFCM { + // GlobalKey linked to MaterialApp navigatorKey + static GlobalKey get navigatorKey => NotifRedDot.navigatorKey; + + static OverlayEntry? _overlayEntry; + static final List _accumulatedMessages = []; + static Timer? _autoDismissTimer; + + /// Initialize listeners for FCM notification click events and foreground messages + static Future init() async { + // 0. Ensure Firebase [DEFAULT] app is initialized + try { + bool hasDefault = false; + for (var app in Firebase.apps) { + if (app.name == '[DEFAULT]') { + hasDefault = true; + break; + } + } + if (!hasDefault) { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + debugPrint('[NotificationFCM] Firebase default app initialized'); + } else { + debugPrint('[NotificationFCM] Firebase default app already exists'); + } + } catch (e) { + debugPrint('[NotificationFCM] Error initializing Firebase: $e'); + } + + // 1. Request push notification permissions + try { + await FirebaseMessaging.instance.requestPermission( + alert: true, + announcement: false, + badge: true, + carPlay: false, + criticalAlert: false, + provisional: false, + sound: true, + ); + } catch (e) { + debugPrint('Error requesting notification permission: $e'); + } + + // 2. Set Background Message Handler + try { + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + } catch (e) { + debugPrint('Error setting background message handler: $e'); + } + + // 3. Listen for standard FCM Foreground Messages + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + _handleIncomingRemoteMessage(message); + }); + + // 4. Handle notification when the app is in the background and opened by tapping it + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { + _handleRemoteMessageOpened(message); + }); + + // 5. Handle notification when the app was terminated and opened by tapping it + try { + final RemoteMessage? initialMessage = await FirebaseMessaging.instance.getInitialMessage(); + if (initialMessage != null) { + _handleRemoteMessageOpened(initialMessage); + } + } catch (e) { + debugPrint('Error getting initial message: $e'); + } + + // Supabase Realtime stream untuk output_alat hanya diaktifkan SETELAH login + // via NotificationFCM.subscribeOutputAlatForCurrentUser(namaUser) + } + + /// Fetch FCM Token and save to Supabase + static Future saveTokenToSupabase(String userId, String role) async { + try { + final token = await FirebaseMessaging.instance.getToken(); + if (token == null) { + debugPrint('[FCM] Failed to get device token'); + return; + } + debugPrint('[FCM] Device token fetched: $token'); + + // Save token to Supabase fcm_tokens table + await Supabase.instance.client.from('fcm_tokens').upsert( + { + 'user_id': userId, + 'role': role, + 'token': token, + }, + onConflict: 'user_id, token', + ); + debugPrint('[FCM] Token saved to Supabase successfully for user $userId'); + } catch (e) { + debugPrint('[FCM] Error saving token to Supabase: $e'); + } + } + + static StreamSubscription>>? _outputAlatSubscription; + static final Set _processedScanIds = {}; + + /// Aktifkan stream output_alat untuk user yang sedang login. + /// Hanya panggil setelah login berhasil dan nama user sudah diketahui. + /// Filter dilakukan di level Supabase (.eq) sehingga hanya baris milik user ini + /// yang dikirim ke perangkat — tidak ada kebocoran data ke user lain. + static void subscribeOutputAlatForCurrentUser(String namaUser) { + if (namaUser.isEmpty) { + debugPrint('[SupabaseStream] subscribeOutputAlatForCurrentUser called with empty name, skipping.'); + return; + } + + _outputAlatSubscription?.cancel(); + _processedScanIds.clear(); + final supabase = Supabase.instance.client; + + debugPrint('[SupabaseStream] Subscribing output_alat for user: $namaUser'); + + try { + _outputAlatSubscription = supabase + .from('output_alat') + .stream(primaryKey: ['id']) + .eq('nama_user', namaUser) // ← filter server-side: hanya baris milik user ini + .order('created_at', ascending: false) + .listen( + (rows) { + if (rows.isEmpty) return; + + // Pada muat pertama, simpan semua ID lama agar tidak memicu notifikasi lama + if (_processedScanIds.isEmpty) { + for (final row in rows) { + final id = row['id']?.toString(); + if (id != null) _processedScanIds.add(id); + } + debugPrint('[SupabaseStream] First load: cached ${_processedScanIds.length} existing scans'); + return; + } + + // Deteksi baris yang benar-benar baru di-insert + for (final row in rows) { + final id = row['id']?.toString(); + if (id != null && !_processedScanIds.contains(id)) { + _processedScanIds.add(id); + + final rowNama = row['nama_user']?.toString() ?? ''; + debugPrint('[SupabaseStream] New scan received for $rowNama (id: $id)'); + + final title = 'Aplikasi Absensi'; + final body = rowNama.isNotEmpty + ? '$rowNama telah melakukan absensi' + : 'Absensi berhasil'; + + WidgetsBinding.instance.addPostFrameCallback((_) { + _showEntrigOverlay(title: title, body: body); + }); + } + } + }, + onError: (e) { + debugPrint('[SupabaseStream] output_alat error: $e'); + }, + ); + debugPrint('[SupabaseStream] Subscribed to output_alat for "$namaUser"'); + } catch (e) { + debugPrint('[SupabaseStream] Error subscribing to output_alat: $e'); + } + } + + /// Hentikan stream output_alat (panggil saat logout) + static void unsubscribeOutputAlat() { + _outputAlatSubscription?.cancel(); + _outputAlatSubscription = null; + _processedScanIds.clear(); + debugPrint('[SupabaseStream] Unsubscribed from output_alat'); + } + + /// Tampilkan overlay di atas app dengan judul dan isi notifikasi Entrig + static void _showEntrigOverlay({required String title, required String body}) { + final context = navigatorKey.currentContext; + if (context == null) { + debugPrint('[Overlay] navigatorKey.currentContext is null!'); + return; + } + + _autoDismissTimer?.cancel(); + if (_overlayEntry != null) { + try { + if (_overlayEntry!.mounted) { + _overlayEntry!.remove(); + } + } catch (e) { + debugPrint('[Overlay] Remove previous entry error: $e'); + } finally { + _overlayEntry = null; + } + } + + _overlayEntry = OverlayEntry( + builder: (ctx) => Positioned( + top: MediaQuery.of(ctx).padding.top + 8, + left: 12, + right: 12, + child: Material( + elevation: 6, + borderRadius: BorderRadius.circular(16), + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFF2563EB).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.notifications_active, + color: Color(0xFF2563EB), size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 13, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 2), + Text( + body, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + GestureDetector( + onTap: _dismissOverlay, + child: const Icon(Icons.close, size: 18, color: Color(0xFF94A3B8)), + ), + ], + ), + ), + ), + ), + ); + + try { + final overlay = Overlay.of(context, rootOverlay: true); + overlay.insert(_overlayEntry!); + debugPrint('[Overlay] Entrig overlay shown successfully: $body'); + } catch (e) { + debugPrint('[Overlay] Insert error: $e'); + } + + _autoDismissTimer = Timer(const Duration(seconds: 5), _dismissOverlay); + } + + // ─── Filter Logic ─────────────────────────────────────────────────────────── + + /// Check if the notification's data payload matches the logged-in student + static Future _shouldShowNotification(Map? data) async { + if (data == null || data.isEmpty) return false; + + final prefs = await SharedPreferences.getInstance(); + final role = prefs.getString('user_role'); + + final idMurid = _extractIdMurid(data); + final namaUser = _extractNamaUser(data); + + if (role == 'admin') { + return false; // Admin tidak melihat notifikasi hardware secara individu + } + + if (role == 'guru') { + final loggedInName = prefs.getString('guru_nama'); + if (loggedInName != null && namaUser != null && namaUser.isNotEmpty) { + return loggedInName.trim().toLowerCase() == namaUser.trim().toLowerCase(); + } + return false; + } + + // 1. Cek berdasarkan id_murid (UUID) - 100% akurat + if (idMurid != null && idMurid.isNotEmpty) { + final loggedInId = prefs.getString('murid_id_tabel'); + if (loggedInId != null && loggedInId.isNotEmpty) { + final matches = loggedInId.trim().toLowerCase() == idMurid.trim().toLowerCase(); + debugPrint('[NotificationFCM] Filter by ID - Logged in: "$loggedInId", Incoming: "$idMurid". Match: $matches'); + return matches; + } + } + + // 2. Fallback: Cek berdasarkan nama_user (String) + if (namaUser != null && namaUser.isNotEmpty) { + final matches = await _shouldShowNotificationByNama(namaUser); + debugPrint('[NotificationFCM] Filter by Name - Incoming: "$namaUser". Match: $matches'); + return matches; + } + + return false; + } + + /// Ekstrak id_murid secara dinamis dari map data + static String? _extractIdMurid(Map data) { + if (data.containsKey('id_murid')) { + final val = data['id_murid']; + if (val is Map) { + return val['id_tabel']?.toString() ?? val['id']?.toString(); + } + return val?.toString(); + } + for (var key in data.keys) { + final k = key.toString().toLowerCase(); + if (k == 'id_murid' || k == 'murid_id' || k == 'id_tabel') { + final val = data[key]; + if (val is Map) { + return val['id_tabel']?.toString() ?? val['id']?.toString(); + } + return val?.toString(); + } + } + return null; + } + + /// Filter berdasarkan nama string langsung + static Future _shouldShowNotificationByNama(String namaUser) async { + if (namaUser.isEmpty) return false; + + final prefs = await SharedPreferences.getInstance(); + String? loggedInStudentName = prefs.getString('murid_nama'); + + // If cache not found, fetch it from Supabase and cache it + if (loggedInStudentName == null || loggedInStudentName.isEmpty) { + final nis = prefs.getString('murid_nis'); + final userId = prefs.getString('user_id_admin'); + if (nis != null && userId != null) { + try { + final response = await Supabase.instance.client + .from('murid') + .select('nama') + .eq('user_id', userId) + .eq('nis', nis) + .maybeSingle(); + + if (response != null) { + loggedInStudentName = response['nama']?.toString(); + if (loggedInStudentName != null) { + await prefs.setString('murid_nama', loggedInStudentName); + } + } + } catch (e) { + debugPrint('Error fetching student name for notification filter: $e'); + } + } + } + + if (loggedInStudentName != null && loggedInStudentName.isNotEmpty) { + final matches = loggedInStudentName.trim().toLowerCase() == namaUser.trim().toLowerCase(); + debugPrint('Notification filter - Logged in: "$loggedInStudentName", Incoming: "$namaUser". Match: $matches'); + return matches; + } + + return false; + } + + /// Extract nama_user field dynamically from map (handles nested objects from foreign keys) + static String? _extractNamaUser(Map data) { + if (data.containsKey('nama_user')) { + final val = data['nama_user']; + if (val is Map) { + return val['name']?.toString() ?? val['nama']?.toString(); + } + return val?.toString(); + } + + // Case insensitive fallback + for (var key in data.keys) { + final k = key.toString().toLowerCase(); + if (k == 'nama_user' || k == 'nama' || k == 'user_name') { + final val = data[key]; + if (val is Map) { + return val['name']?.toString() ?? val['nama']?.toString(); + } + return val?.toString(); + } + } + return null; + } + + static Future _handleIncomingRemoteMessage(RemoteMessage message) async { + debugPrint('[FCM] Foreground message received: notification=${message.notification?.title}/${message.notification?.body}, data=${message.data}'); + + if (!await _shouldShowNotification(message.data)) { + debugPrint('[FCM] Foreground message ignored (not for logged in user)'); + return; + } + + final dataNamaUser = _extractNamaUser(message.data); + final notificationBody = message.notification?.body ?? message.data['body']?.toString() ?? message.data['text']?.toString() ?? ''; + final notificationTitle = message.notification?.title ?? message.data['title']?.toString() ?? 'Aplikasi Absensi'; + + final prefs = await SharedPreferences.getInstance(); + final loggedInNama = prefs.getString('murid_nama') ?? ''; + + String body = 'Siswa telah melakukan absensi'; + + if (dataNamaUser != null && dataNamaUser.isNotEmpty && dataNamaUser != '...') { + body = '$dataNamaUser telah melakukan absensi'; + } else if (notificationBody.isNotEmpty) { + body = notificationBody; + } else if (loggedInNama.isNotEmpty) { + body = '$loggedInNama telah melakukan absensi'; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + _showEntrigOverlay(title: notificationTitle, body: body); + }); + } + + static Future _handleRemoteMessageOpened(RemoteMessage message) async { + debugPrint('[FCM] Notification opened by user: data=${message.data}'); + + if (!await _shouldShowNotification(message.data)) { + debugPrint('[FCM] Opened notification ignored (not for logged in user)'); + return; + } + + final context = navigatorKey.currentContext; + if (context == null) return; + + final prefs = await SharedPreferences.getInstance(); + final nis = prefs.getString('murid_nis') ?? ''; + + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => AbsenHistory(nis: nis)), + ); + } + } + + static void _dismissOverlay() { + _autoDismissTimer?.cancel(); + _autoDismissTimer = null; + if (_overlayEntry != null) { + try { + if (_overlayEntry!.mounted) { + _overlayEntry!.remove(); + } + } catch (e) { + debugPrint('[Overlay] Dismiss error: $e'); + } finally { + _overlayEntry = null; + } + } + _accumulatedMessages.clear(); + } +} + diff --git a/lib/dashboard/d_c_murid/function/FCM_absen.dart/grup_kelas_n.dart b/lib/dashboard/d_c_murid/function/FCM_absen.dart/grup_kelas_n.dart new file mode 100644 index 0000000..69943eb --- /dev/null +++ b/lib/dashboard/d_c_murid/function/FCM_absen.dart/grup_kelas_n.dart @@ -0,0 +1,159 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_grup_kelas.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/grup_kelas.dart'; + +/// Notification handler for Grup Kelas chat. +/// Must call [GrupKelasNotification.init()] once after login. +/// Call [GrupKelasNotification.dispose()] on logout. +class GrupKelasNotification { + // GlobalKey provided by the root MaterialApp (shared with NotificationFCM) + static GlobalKey? navigatorKey; + + static OverlayEntry? _overlayEntry; + static Timer? _autoDismissTimer; + + static String? _currentUserId; + static String? _currentUserRole; + static String? _currentClassId; + + // Supabase stream subscription + static StreamSubscription>>? _streamSubscription; + static DateTime? _lastSeenAt; + + static final ValueNotifier hasUnread = ValueNotifier(false); + + static Future setUnread(bool value) async { + hasUnread.value = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('grup_kelas_has_unread', value); + if (!value) { + await prefs.setString('grup_kelas_last_read', DateTime.now().toUtc().toIso8601String()); + _lastSeenAt = DateTime.now().toUtc(); + } + if (!value) { + await prefs.setString('grup_kelas_last_read', DateTime.now().toUtc().toIso8601String()); + _lastSeenAt = DateTime.now().toUtc(); + } + } catch (e) { + debugPrint('[GrupKelasNotification] setUnread error: $e'); + } + } + + /// Initialize the listener. Safe to call multiple times — will re-initialize. + static Future init({GlobalKey? key}) async { + if (key != null) navigatorKey = key; + + await dispose(); // bersihkan state sebelumnya + + try { + final info = await DbGrupKelas.getCurrentUserRoleAndId(); + if (info == null) return; + + _currentUserId = info['id']; + _currentUserRole = info['role']; + _currentClassId = info['id_class']; + + if (_currentClassId == null || _currentClassId!.isEmpty) return; + + final prefs = await SharedPreferences.getInstance(); + hasUnread.value = prefs.getBool('grup_kelas_has_unread') ?? false; + + + + _subscribeRealtime(); + + debugPrint('[GrupKelasNotification] initialized (role: $_currentUserRole, class: $_currentClassId)'); + } catch (e) { + debugPrint('[GrupKelasNotification] init error: $e'); + } + } + + // ─── Supabase Realtime ──────────────────────────────────────────────────── + + static void _subscribeRealtime() { + final supabase = Supabase.instance.client; + _lastSeenAt = DateTime.now().toUtc(); + + _streamSubscription = supabase + .from('grup_kelas') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: false) + .listen( + (rows) { + // Filter: only messages for the same class_id + final filtered = _currentClassId != null + ? rows.where((r) => r['id_class']?.toString() == _currentClassId).toList() + : rows; + + // Filter: only new messages (after init / lastSeenAt) + final newMsgs = filtered.where((row) { + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + return t != null && t.isAfter(_lastSeenAt!.subtract(const Duration(minutes: 5))); + }).toList(); + + if (newMsgs.isEmpty) return; + + // Update lastSeenAt + for (final row in newMsgs) { + final t = DateTime.tryParse(row['created_at'].toString()); + if (t != null && t.isAfter(_lastSeenAt!)) _lastSeenAt = t; + } + + debugPrint('[GrupKelasNotification] stream → ${newMsgs.length} pesan baru'); + _handleRealtimeInsert(newMsgs.first); + }, + onError: (e) { + debugPrint('[GrupKelasNotification] stream error: $e'); + }, + ); + } + + static void _handleRealtimeInsert(Map row) { + // Skip if sent by ourselves + final pengirimMurid = row['pengirim_murid']?.toString(); + final pengirimGuru = row['pengirim_guru']?.toString(); + final pengirimAdmin = row['pengirim_admin']?.toString(); + final senderId = pengirimMurid ?? pengirimGuru ?? pengirimAdmin; + + if (senderId != null && senderId == _currentUserId) { + return; + } + + final text = row['text']?.toString() ?? ''; + debugPrint('[GrupKelasNotification] Realtime → pesan baru: "$text"'); + + setUnread(true); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _showOverlayCard(text: text); + }); + } + + static Future dispose() async { + _streamSubscription?.cancel(); + _streamSubscription = null; + _lastSeenAt = null; + _dismissOverlay(); + } + + // ─── Overlay UI ─────────────────────────────────────────────────────────── + + static void _showOverlayCard({required String text}) { + // Disabled overlay popups as requested, keeping only red dot/badge updates + return; + } + + static void _dismissOverlay() { + _autoDismissTimer?.cancel(); + _autoDismissTimer = null; + if (_overlayEntry != null) { + _overlayEntry!.remove(); + _overlayEntry = null; + } + } +} diff --git a/lib/dashboard/d_c_murid/function/FCM_absen.dart/grup_sekolah_n.dart b/lib/dashboard/d_c_murid/function/FCM_absen.dart/grup_sekolah_n.dart new file mode 100644 index 0000000..b274f7d --- /dev/null +++ b/lib/dashboard/d_c_murid/function/FCM_absen.dart/grup_sekolah_n.dart @@ -0,0 +1,191 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_grup_kelas.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/grup_sekolah.dart'; + +/// Notification handler for Grup Sekolah chat. +/// +/// Menggunakan dua mekanisme: +/// 1. **Supabase Realtime** — foreground notification utama (tidak bergantung FCM/Entrig). +/// Langsung listen INSERT pada tabel `grup_sekolah` dan tampilkan overlay. +/// 2. **Entrig callback** — fallback dari [NotificationFCM] jika event datang via FCM. +/// +/// Must call [GrupSekolahNotification.init()] once after login. +/// Call [GrupSekolahNotification.dispose()] on logout. +class GrupSekolahNotification { + // GlobalKey provided by the root MaterialApp (shared with NotificationFCM) + static GlobalKey? navigatorKey; + + static OverlayEntry? _overlayEntry; + static Timer? _autoDismissTimer; + + static String? _currentUserId; + static String? _currentUserRole; + static String? _currentAdminId; // untuk filter per-sekolah (user_id di grup_sekolah) + + // Supabase stream subscription (lebih reliable dari onPostgresChanges) + static StreamSubscription>>? _streamSubscription; + static DateTime? _lastSeenAt; // untuk filter pesan baru saja + + static final ValueNotifier hasUnread = ValueNotifier(false); + + static Future setUnread(bool value) async { + hasUnread.value = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('grup_sekolah_has_unread', value); + if (!value) { + await prefs.setString('grup_sekolah_last_read', DateTime.now().toUtc().toIso8601String()); + _lastSeenAt = DateTime.now().toUtc(); + } + if (!value) { + await prefs.setString('grup_sekolah_last_read', DateTime.now().toUtc().toIso8601String()); + _lastSeenAt = DateTime.now().toUtc(); + } + } catch (e) { + debugPrint('[GrupSekolahNotification] setUnread error: $e'); + } + } + + /// Initialize listener. Safe to call multiple times — will re-initialize. + static Future init({GlobalKey? key}) async { + if (key != null) navigatorKey = key; + + dispose(); // bersihkan state sebelumnya + + try { + final info = await DbGrupKelas.getCurrentUserRoleAndId(); + if (info == null) return; + + _currentUserId = info['id']; + _currentUserRole = info['role']; + + // Ambil admin_id dari SharedPreferences untuk filter per-sekolah + final prefs = await SharedPreferences.getInstance(); + _currentAdminId = prefs.getString('user_id_admin'); + + // Load status unread + hasUnread.value = prefs.getBool('grup_sekolah_has_unread') ?? false; + + + + // 2. Supabase Realtime (mekanisme utama foreground) + _subscribeRealtime(); + + debugPrint( + '[GrupSekolahNotification] initialized ' + '(role: $_currentUserRole, adminId: $_currentAdminId)', + ); + } catch (e) { + debugPrint('[GrupSekolahNotification] init error: $e'); + } + } + + // ─── Supabase Realtime ──────────────────────────────────────────────────── + + static void _subscribeRealtime() async { + final supabase = Supabase.instance.client; + final prefs = await SharedPreferences.getInstance(); + final lastReadStr = prefs.getString('grup_sekolah_last_read'); + _lastSeenAt = lastReadStr != null ? DateTime.parse(lastReadStr) : DateTime.now().toUtc(); + + _streamSubscription = supabase + .from('grup_sekolah') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: false) + .listen( + (rows) { + // Filter: hanya pesan dari sekolah yang sama (client-side) + final filtered = _currentAdminId != null + ? rows.where((r) => r['user_id']?.toString() == _currentAdminId).toList() + : rows; + + // Filter: hanya pesan baru (setelah waktu init / lastSeenAt) + final newMsgs = filtered.where((row) { + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + return t != null && t.isAfter(_lastSeenAt!.subtract(const Duration(minutes: 5))); + }).toList(); + + if (newMsgs.isEmpty) return; + + // Update lastSeenAt ke pesan paling baru + for (final row in newMsgs) { + final t = DateTime.tryParse(row['created_at'].toString()); + if (t != null && t.isAfter(_lastSeenAt!)) _lastSeenAt = t; + } + + debugPrint('[GrupSekolahNotification] stream → ${newMsgs.length} pesan baru'); + // Tampilkan notifikasi untuk pesan pertama (terbaru) + _handleRealtimeInsert(newMsgs.first); + }, + onError: (e) { + debugPrint('[GrupSekolahNotification] stream error: $e'); + }, + ); + + debugPrint('[GrupSekolahNotification] stream subscribed (lastSeenAt: $_lastSeenAt)'); + } + + /// Tangani INSERT baru dari Supabase Realtime. + static void _handleRealtimeInsert(Map row) { + // Skip jika pesan dari user ini sendiri + final pengirimMurid = row['pengirim_murid']?.toString(); + final pengirimGuru = row['pengirim_guru']?.toString(); + final pengirimAdmin = row['pengirim_admin']?.toString(); + final senderId = pengirimMurid ?? pengirimGuru ?? pengirimAdmin; + + if (senderId != null && senderId == _currentUserId) { + debugPrint('[GrupSekolahNotification] Skip own message'); + return; + } + + final text = row['text']?.toString() ?? ''; + debugPrint('[GrupSekolahNotification] Realtime → pesan baru: "$text"'); + + setUnread(true); + + // Tampilkan di frame berikutnya agar Navigator & Overlay sudah siap + WidgetsBinding.instance.addPostFrameCallback((_) { + _showOverlayCard( + title: 'Grup Sekolah', + body: 'Pesan Masuk', + text: text, + ); + }); + } + + // ─── Lifecycle ──────────────────────────────────────────────────────────── + + /// Bersihkan listener dan sembunyikan overlay. + static void dispose() { + // Cancel stream subscription + _streamSubscription?.cancel(); + _streamSubscription = null; + _lastSeenAt = null; + + _dismissOverlay(); + } + + // ─── Overlay UI ─────────────────────────────────────────────────────────── + + static void _showOverlayCard({ + required String title, + required String body, + required String text, + }) { + // Disabled overlay popups as requested, keeping only red dot/badge updates + return; + } + + static void _dismissOverlay() { + _autoDismissTimer?.cancel(); + _autoDismissTimer = null; + if (_overlayEntry != null) { + _overlayEntry!.remove(); + _overlayEntry = null; + } + } +} diff --git a/lib/dashboard/d_c_murid/function/absen/absen_history.dart b/lib/dashboard/d_c_murid/function/absen/absen_history.dart new file mode 100644 index 0000000..3213479 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/absen/absen_history.dart @@ -0,0 +1,765 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_absensi.dart'; + +class AbsenHistory extends StatefulWidget { + final String nis; + + const AbsenHistory({super.key, required this.nis}); + + @override + State createState() => _AbsenHistoryState(); +} + +class _AbsenHistoryState extends State { + bool _isLoading = true; + String _error = ''; + + List> _allAbsen = []; + int _activeTabIndex = 0; // 0 for Harian, 1 for Mingguan, 2 for Bulanan + final Set _expandedWeeks = {}; // Keep track of expanded week groups + final Set _expandedMonths = {}; // Keep track of expanded month groups + + @override + void initState() { + super.initState(); + _fetchData(); + } + + Future _fetchData() async { + if (widget.nis == '...') { + setState(() { + _isLoading = true; + _error = ''; + }); + return; + } + + if (widget.nis.isEmpty || widget.nis == '-') { + setState(() { + _isLoading = false; + _error = 'NIS tidak ditemukan'; + }); + return; + } + + setState(() { + _isLoading = true; + _error = ''; + }); + + try { + final db = DbAbsensi(); + final data = await db.getAbsensi(widget.nis); + + // Urutkan data dari tanggal terbaru ke terlama + data.sort((a, b) => b['date'].toString().compareTo(a['date'].toString())); + + if (mounted) { + setState(() { + _allAbsen = data; + _isLoading = false; + + // Otomatis expand bulan teratas (paling baru) + if (data.isNotEmpty) { + try { + final latestDate = DateTime.parse(data.first['date'].toString()); + final firstOfMonth = DateTime(latestDate.year, latestDate.month, 1); + _expandedMonths.add(firstOfMonth); + } catch (_) {} + } + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Color _getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'hadir': return Colors.green; + case 'izin': return Colors.blue; + case 'sakit': return Colors.orange; + case 'alpha': return Colors.red; + case 'terlambat': return Colors.amber; + case 'libur': return Colors.purple; + default: return Colors.grey; + } + } + + // Helper to group attendance records by calendar week (Monday to Sunday) + // Hanya menampilkan minggu pada bulan berjalan + Map>> get _groupedWeeks { + final Map>> groups = {}; + final now = DateTime.now(); + for (var row in _allAbsen) { + final dateStr = row['date'].toString(); + try { + final date = DateTime.parse(dateStr); + // Filter hanya untuk bulan berjalan + if (date.year != now.year || date.month != now.month) continue; + + // Find Monday of that date's week + final monday = DateTime(date.year, date.month, date.day - (date.weekday - 1)); + if (!groups.containsKey(monday)) { + groups[monday] = []; + } + groups[monday]!.add(row); + } catch (_) {} + } + + // Sort keys descending (latest week first) + final sortedKeys = groups.keys.toList()..sort((a, b) => b.compareTo(a)); + final Map>> sortedGroups = {}; + for (var key in sortedKeys) { + groups[key]!.sort((a, b) => b['date'].toString().compareTo(a['date'].toString())); + sortedGroups[key] = groups[key]!; + } + return sortedGroups; + } + + // Helper to group attendance records by month + Map>> get _groupedMonths { + final Map>> groups = {}; + for (var row in _allAbsen) { + final dateStr = row['date'].toString(); + try { + final date = DateTime.parse(dateStr); + final firstOfMonth = DateTime(date.year, date.month, 1); + if (!groups.containsKey(firstOfMonth)) { + groups[firstOfMonth] = []; + } + groups[firstOfMonth]!.add(row); + } catch (_) {} + } + + final sortedKeys = groups.keys.toList()..sort((a, b) => b.compareTo(a)); + final Map>> sortedGroups = {}; + for (var key in sortedKeys) { + sortedGroups[key] = groups[key]!; + } + return sortedGroups; + } + + Widget _buildDailyCard(Map row) { + final dateStr = row['date'].toString(); + String displayDate = dateStr; + String displayDay = ''; + try { + final d = DateTime.parse(dateStr); + displayDate = DateFormat('dd MMM yyyy').format(d); + displayDay = DateFormat('EEEE').format(d); + } catch (_) {} + + final status = DbAbsensi.getStatus(row); + final color = _getStatusColor(status); + final masuk = row['masuk'] ?? '--:--'; + + return Container( + margin: const EdgeInsets.only(bottom: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.01), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Date Badge + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + displayDay.length >= 3 ? displayDay.substring(0, 3).toUpperCase() : displayDay.toUpperCase(), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 10, + fontWeight: FontWeight.bold, + color: Color(0xFF2563EB), + ), + ), + const SizedBox(height: 2), + Text( + displayDate.length >= 2 ? displayDate.substring(0, 2) : displayDate, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + + // Info Column + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + status, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ), + Text( + displayDate, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF64748B), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const Icon(Icons.login, size: 14, color: Color(0xFF94A3B8)), + const SizedBox(width: 4), + Text( + masuk, + style: const TextStyle(fontFamily: 'Inter', fontSize: 13, color: Color(0xFF475569)), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildWeeklyCard(DateTime weekStart, List> records) { + final weekEnd = weekStart.add(const Duration(days: 6)); + final isExpanded = _expandedWeeks.contains(weekStart); + + int hadir = 0; + int sakit = 0; + int izin = 0; + int alpha = 0; + int terlambat = 0; + + for (var r in records) { + final status = DbAbsensi.getStatus(r).toLowerCase(); + if (status == 'hadir') { + hadir++; + } else if (status == 'sakit') { + sakit++; + } else if (status == 'izin') { + izin++; + } else if (status == 'alpha') { + alpha++; + } else if (status == 'terlambat') { + terlambat++; + } + } + + final dateRangeStr = "${DateFormat('dd MMM').format(weekStart)} - ${DateFormat('dd MMM yyyy').format(weekEnd)}"; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.01), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header Tappable to Toggle Expand + InkWell( + onTap: () { + setState(() { + if (isExpanded) { + _expandedWeeks.remove(weekStart); + } else { + _expandedWeeks.add(weekStart); + } + }); + }, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.date_range, color: Color(0xFF2563EB), size: 20), + const SizedBox(width: 8), + Text( + dateRangeStr, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + ], + ), + Icon( + isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + color: const Color(0xFF64748B), + size: 20, + ), + ], + ), + const SizedBox(height: 12), + // Summary Badges Row + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildStatBadge('Hadir', hadir, Colors.green), + _buildStatBadge('Sakit', sakit, Colors.orange), + _buildStatBadge('Izin', izin, Colors.blue), + _buildStatBadge('Alpha', alpha, Colors.red), + _buildStatBadge('Terlambat', terlambat, Colors.amber), + ], + ), + ), + ], + ), + ), + ), + + // Expandable Child List + if (isExpanded) ...[ + const Divider(height: 1, thickness: 1, color: Color(0xFFF1F5F9)), + Container( + color: const Color(0xFFF8FAFC), + padding: const EdgeInsets.all(12), + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: records.length, + itemBuilder: (context, idx) { + return _buildDailyCard(records[idx]); + }, + ), + ), + ], + ], + ), + ); + } + + Widget _buildMonthlyCard(DateTime monthStart, List> records) { + final isExpanded = _expandedMonths.contains(monthStart); + + int hadir = 0; + int sakit = 0; + int izin = 0; + int alpha = 0; + + for (var r in records) { + final status = DbAbsensi.getStatus(r).toLowerCase(); + if (status == 'hadir' || status == 'terlambat') { + hadir++; + } else if (status == 'sakit') { + sakit++; + } else if (status == 'izin') { + izin++; + } else if (status == 'alpha') { + alpha++; + } + } + + final monthStr = DateFormat('MMMM yyyy').format(monthStart); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.01), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header Tappable to Toggle Expand + InkWell( + onTap: () { + setState(() { + if (isExpanded) { + _expandedMonths.remove(monthStart); + } else { + _expandedMonths.add(monthStart); + } + }); + }, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.calendar_month, color: Color(0xFF2563EB), size: 20), + const SizedBox(width: 8), + Text( + monthStr, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + ], + ), + Icon( + isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + color: const Color(0xFF64748B), + size: 20, + ), + ], + ), + ), + ), + + // Expandable Child List showing ONLY H S A I stats detail list (Terlambat is merged into Hadir) + if (isExpanded) ...[ + const Divider(height: 1, thickness: 1, color: Color(0xFFF1F5F9)), + Container( + color: const Color(0xFFF8FAFC), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: Column( + children: [ + _buildMonthlyStatRow('Hadir', hadir, Colors.green, Icons.check_circle_outline), + const SizedBox(height: 12), + _buildMonthlyStatRow('Sakit', sakit, Colors.orange, Icons.sick_outlined), + const SizedBox(height: 12), + _buildMonthlyStatRow('Izin', izin, Colors.blue, Icons.assignment_outlined), + const SizedBox(height: 12), + _buildMonthlyStatRow('Alpha', alpha, Colors.red, Icons.cancel_outlined), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildMonthlyStatRow(String label, int count, Color color, IconData icon) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 22), + const SizedBox(width: 12), + Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + "$count Hari", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ), + ], + ), + ); + } + + Widget _buildStatBadge(String label, int count, Color color) { + return Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.2), width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + "$label: $count", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final Map>> grouped = _groupedWeeks; + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + titleSpacing: 20, + title: const Text('Riwayat Absensi'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: Column( + children: [ + // Sleek Toggle segment (Harian, Mingguan, Bulanan) + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Container( + height: 44, + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // Harian Tab + Expanded( + child: GestureDetector( + onTap: () => setState(() => _activeTabIndex = 0), + child: Container( + decoration: BoxDecoration( + color: _activeTabIndex == 0 ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: _activeTabIndex == 0 + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ] + : null, + ), + margin: const EdgeInsets.all(4), + alignment: Alignment.center, + child: Text( + 'Harian', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.bold, + color: _activeTabIndex == 0 ? const Color(0xFF2563EB) : const Color(0xFF64748B), + ), + ), + ), + ), + ), + // Mingguan Tab + Expanded( + child: GestureDetector( + onTap: () => setState(() => _activeTabIndex = 1), + child: Container( + decoration: BoxDecoration( + color: _activeTabIndex == 1 ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: _activeTabIndex == 1 + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ] + : null, + ), + margin: const EdgeInsets.all(4), + alignment: Alignment.center, + child: Text( + 'Mingguan', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.bold, + color: _activeTabIndex == 1 ? const Color(0xFF2563EB) : const Color(0xFF64748B), + ), + ), + ), + ), + ), + // Bulanan Tab + Expanded( + child: GestureDetector( + onTap: () => setState(() => _activeTabIndex = 2), + child: Container( + decoration: BoxDecoration( + color: _activeTabIndex == 2 ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: _activeTabIndex == 2 + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ] + : null, + ), + margin: const EdgeInsets.all(4), + alignment: Alignment.center, + child: Text( + 'Bulanan', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.bold, + color: _activeTabIndex == 2 ? const Color(0xFF2563EB) : const Color(0xFF64748B), + ), + ), + ), + ), + ), + ], + ), + ), + ), + const Divider(height: 1, thickness: 1, color: Color(0xFFE2E8F0)), + + // Content + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error.isNotEmpty + ? Center(child: Text('Error: $_error', style: const TextStyle(color: Colors.red))) + : _allAbsen.isEmpty + ? const Center( + child: Text( + 'Tidak ada data absensi.', + style: TextStyle(fontFamily: 'Inter', color: Color(0xFF94A3B8)), + ), + ) + : _activeTabIndex == 0 + // Harian List View + ? ListView.builder( + padding: const EdgeInsets.all(20), + itemCount: _allAbsen.length, + itemBuilder: (context, index) { + return _buildDailyCard(_allAbsen[index]); + }, + ) + : _activeTabIndex == 1 + // Mingguan List View + ? ListView.builder( + padding: const EdgeInsets.all(20), + itemCount: grouped.length, + itemBuilder: (context, index) { + final key = grouped.keys.elementAt(index); + final val = grouped[key]!; + return _buildWeeklyCard(key, val); + }, + ) + // Bulanan List View + : ListView.builder( + padding: const EdgeInsets.all(20), + itemCount: _groupedMonths.length, + itemBuilder: (context, index) { + final key = _groupedMonths.keys.elementAt(index); + final val = _groupedMonths[key]!; + return _buildMonthlyCard(key, val); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/absen/absen_preview.dart b/lib/dashboard/d_c_murid/function/absen/absen_preview.dart new file mode 100644 index 0000000..3627e23 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/absen/absen_preview.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_absensi.dart'; + +class AbsenPreview extends StatefulWidget { + final String nis; + + const AbsenPreview({super.key, required this.nis}); + + @override + State createState() => _AbsenPreviewState(); +} + +class _AbsenPreviewState extends State { + bool _isLoading = true; + String _error = ''; + + Map? _todayAbsen; + + @override + void initState() { + super.initState(); + _fetchData(); + } + + @override + void didUpdateWidget(covariant AbsenPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.nis != widget.nis) { + _fetchData(); + } + } + Future _fetchData() async { + if (widget.nis == '...') { + setState(() { + _isLoading = true; + _error = ''; + }); + return; + } + + if (widget.nis.isEmpty || widget.nis == '-') { + setState(() { + _isLoading = false; + _error = 'NIS tidak ditemukan'; + }); + return; + } + + setState(() { + _isLoading = true; + _error = ''; + }); + + try { + final db = DbAbsensi(); + final data = await db.getAbsensi(widget.nis); + + final now = DateTime.now(); + final todayStr = DateFormat('yyyy-MM-dd').format(now); + + Map? today; + + for (var row in data) { + final dateStr = row['date'].toString(); + + // Cek hari ini + if (dateStr == todayStr || dateStr.startsWith(todayStr)) { + today = row; + break; + } + } + + if (mounted) { + setState(() { + _todayAbsen = today; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Color _getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'hadir': return Colors.green; + case 'izin': return Colors.blue; + case 'sakit': return Colors.orange; + case 'alpha': return Colors.red; + case 'terlambat': return Colors.amber; + case 'libur': return Colors.purple; + default: return Colors.grey; + } + } + + Widget _buildStatusBadge(String status) { + final color = _getStatusColor(status); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.5)), + ), + child: Text( + status, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (_error.isNotEmpty) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.red.shade200), + ), + child: Text( + 'Gagal memuat absensi: $_error', + style: TextStyle(color: Colors.red.shade700, fontFamily: 'Inter'), + ), + ); + } + + final todayStatus = _todayAbsen != null ? DbAbsensi.getStatus(_todayAbsen!) : 'Belum Absen'; + final masuk = _todayAbsen?['masuk'] ?? '--:--'; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Absen Hari Ini + Container( + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Color(0xFF2563EB), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Absensi Hari Ini', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + DateFormat('dd MMM yyyy').format(DateTime.now()), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Colors.white70, + ), + ), + ], + ), + ), + + // Konten Hari Ini + Padding( + padding: const EdgeInsets.all(20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildTimeBox('Masuk', masuk, Icons.login, Colors.green), + Column( + children: [ + const Text('Status', style: TextStyle(fontFamily: 'Inter', fontSize: 12, color: Colors.grey)), + const SizedBox(height: 8), + _buildStatusBadge(todayStatus), + ], + ), + ], + ), + ), + + ], + ), + ); + } + + Widget _buildTimeBox(String label, String time, IconData icon, Color color) { + return Column( + children: [ + Row( + children: [ + Icon(icon, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 12, color: Colors.grey)), + ], + ), + const SizedBox(height: 8), + Text( + time, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/chat_admin.dart b/lib/dashboard/d_c_murid/function/f_Pesan/chat_admin.dart new file mode 100644 index 0000000..cc8e732 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/chat_admin.dart @@ -0,0 +1,462 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_pesan_private.dart'; +import 'package:intl/intl.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/d_c_murid/database/storage/image_doc.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/doc_image.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/chat_private_n.dart'; + +class ChatAdminPage extends StatefulWidget { + const ChatAdminPage({super.key}); + + @override + State createState() => _ChatAdminPageState(); +} + +class _ChatAdminPageState extends State { + final TextEditingController _messageController = TextEditingController(); + final DbPesanPrivate _dbPesan = DbPesanPrivate(); + + bool _isLoading = true; + String? _errorMessage; + String? _idAdmin; + String? _idMurid; + String? _userIdAdmin; + + Stream>>? _chatStream; + + @override + void initState() { + super.initState(); + _fetchIdentities(); + } + + Future _fetchIdentities() async { + try { + final prefs = await SharedPreferences.getInstance(); + final nis = prefs.getString('murid_nis'); + _userIdAdmin = prefs.getString('user_id_admin'); + + if (nis == null || _userIdAdmin == null) { + throw Exception('Data login tidak lengkap (NIS atau User ID kosong)'); + } + + // 1. Dapatkan id_murid (id_tabel) + final muridRes = await Supabase.instance.client + .from('murid') + .select('id_tabel') + .eq('user_id', _userIdAdmin!) + .eq('nis', nis) + .single(); + _idMurid = muridRes['id_tabel']?.toString(); + + // 2. Dapatkan id_admin dari user_admin. Kolom `id` di tabel user_admin + // adalah foreign key yang terhubung langsung ke auth.users(id). + final adminRes = await Supabase.instance.client + .from('user_admin') + .select('id') + .eq('id', _userIdAdmin!) + .single(); + _idAdmin = adminRes['id']?.toString(); + + if (_idMurid != null && _idAdmin != null) { + _chatStream = _dbPesan.getChatWithAdminStream(_idMurid!, _idAdmin!); + ChatPrivateNotification.setSenderUnread(_idAdmin!, false); + } + } catch (e) { + _errorMessage = 'Error fetching identities: $e'; + debugPrint(_errorMessage); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + final currentUserId = Supabase.instance.client.auth.currentUser?.id ?? _userIdAdmin; + final data = { + 'text': 'IMAGE_URL:$url', + 'pengirim_murid': _idMurid, + 'penerima_admin': _idAdmin, + 'user_id': currentUserId, + }; + await _dbPesan.sendMessage(data); + _fetchIdentities(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + final currentUserId = Supabase.instance.client.auth.currentUser?.id ?? _userIdAdmin; + final data = { + 'text': 'DOC_URL:$url', + 'pengirim_murid': _idMurid, + 'penerima_admin': _idAdmin, + 'user_id': currentUserId, + }; + await _dbPesan.sendMessage(data); + _fetchIdentities(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + Future _sendMessage() async { + if (_messageController.text.trim().isEmpty) return; + if (_idMurid == null || _idAdmin == null || _userIdAdmin == null) return; + + final text = _messageController.text.trim(); + _messageController.clear(); + + final currentUserId = Supabase.instance.client.auth.currentUser?.id ?? _userIdAdmin; + + final data = { + 'text': text, + 'pengirim_murid': _idMurid, + 'penerima_admin': _idAdmin, + 'user_id': currentUserId, + }; + + try { + await _dbPesan.sendMessage(data); + + // Reload pembacaan database setelah mengirim pesan + if (mounted) { + setState(() { + _chatStream = _dbPesan.getChatWithAdminStream(_idMurid!, _idAdmin!); + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF1F5F9), + appBar: AppBar( + titleSpacing: 0, + title: const Text('Chat Admin Sekolah'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center(child: Padding(padding: const EdgeInsets.all(16), child: Text(_errorMessage!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.red)))) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan (stream null)')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center( + child: Text( + 'Belum ada pesan.\nKirim pesan pertama Anda!', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, fontFamily: 'Inter'), + ), + ); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + final isMe = msg['pengirim_murid'] == _idMurid; + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + _buildMessageInput(), + ], + ), + ); + } + + Widget _buildMessageInput() { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -120), + onSelected: (value) { + if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + ], + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/chat_private.dart b/lib/dashboard/d_c_murid/function/f_Pesan/chat_private.dart new file mode 100644 index 0000000..cd96032 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/chat_private.dart @@ -0,0 +1,909 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:intl/intl.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/d_c_murid/database/storage/image_doc.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/doc_image.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/chat_private_n.dart'; + +class ChatPrivate extends StatefulWidget { + final String receiverId; + final String receiverType; // 'guru' or 'murid' + final String receiverName; + + const ChatPrivate({ + super.key, + required this.receiverId, + required this.receiverType, + required this.receiverName, + }); + + @override + State createState() => _ChatPrivateState(); +} + +class _ChatPrivateState extends State { + final TextEditingController _messageController = TextEditingController(); + final supabase = Supabase.instance.client; + + bool _isLoading = true; + String? _errorMessage; + String? _currentMuridId; + String? _userIdAdmin; + + Stream>>? _chatStream; + + @override + void initState() { + super.initState(); + _initChat(); + ChatPrivateNotification.setSenderUnread(widget.receiverId, false); + } + + Future _initChat() async { + try { + final prefs = await SharedPreferences.getInstance(); + final nis = prefs.getString('murid_nis'); + _userIdAdmin = prefs.getString('user_id_admin'); + + if (nis == null || _userIdAdmin == null) { + throw Exception('Data login tidak lengkap (NIS atau User ID kosong)'); + } + + // Dapatkan id_murid (id_tabel) + final muridRes = await supabase + .from('murid') + .select('id_tabel') + .eq('user_id', _userIdAdmin!) + .eq('nis', nis) + .single(); + _currentMuridId = muridRes['id_tabel']?.toString(); + + if (_currentMuridId != null) { + if (widget.receiverType == 'guru') { + _chatStream = supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_murid']?.toString() == _currentMuridId && e['penerima_guru']?.toString() == widget.receiverId) || + (e['pengirim_guru']?.toString() == widget.receiverId && e['penerima_murid']?.toString() == _currentMuridId)) + .map((e) => e as Map) + .toList()); + } else if (widget.receiverType == 'murid') { + _chatStream = supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_murid']?.toString() == _currentMuridId && e['penerima_murid']?.toString() == widget.receiverId) || + (e['pengirim_murid']?.toString() == widget.receiverId && e['penerima_murid']?.toString() == _currentMuridId)) + .map((e) => e as Map) + .toList()); + } + } else { + _errorMessage = 'Gagal memuat profil Murid.'; + } + } catch (e) { + _errorMessage = 'Error: $e'; + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + final userId = supabase.auth.currentUser?.id ?? _userIdAdmin; + if (widget.receiverType == 'guru') { + await supabase.from('chat_private').insert({ + 'text': 'IMAGE_URL:$url', + 'pengirim_murid': _currentMuridId, + 'penerima_guru': widget.receiverId, + 'user_id': userId, + }); + } else if (widget.receiverType == 'murid') { + await supabase.from('chat_private').insert({ + 'text': 'IMAGE_URL:$url', + 'pengirim_murid': _currentMuridId, + 'penerima_murid': widget.receiverId, + 'user_id': userId, + }); + } + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + final userId = supabase.auth.currentUser?.id ?? _userIdAdmin; + if (widget.receiverType == 'guru') { + await supabase.from('chat_private').insert({ + 'text': 'DOC_URL:$url', + 'pengirim_murid': _currentMuridId, + 'penerima_guru': widget.receiverId, + 'user_id': userId, + }); + } else if (widget.receiverType == 'murid') { + await supabase.from('chat_private').insert({ + 'text': 'DOC_URL:$url', + 'pengirim_murid': _currentMuridId, + 'penerima_murid': widget.receiverId, + 'user_id': userId, + }); + } + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _currentMuridId == null) return; + + _messageController.clear(); + + try { + final userId = supabase.auth.currentUser?.id ?? _userIdAdmin; + + if (widget.receiverType == 'guru') { + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_murid': _currentMuridId, + 'penerima_guru': widget.receiverId, + 'user_id': userId, + }); + } else if (widget.receiverType == 'murid') { + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_murid': _currentMuridId, + 'penerima_murid': widget.receiverId, + 'user_id': userId, + }); + } + + if (mounted) { + setState(() { + _isLoading = true; + }); + await _initChat(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Chat: ${widget.receiverName}'), + backgroundColor: const Color(0xFF2563EB), + iconTheme: const IconThemeData(color: Colors.white), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center(child: Padding(padding: const EdgeInsets.all(16), child: Text(_errorMessage!, style: const TextStyle(color: Colors.red)))) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center(child: Text('Belum ada pesan')); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + final isMe = msg['pengirim_murid']?.toString() == _currentMuridId; + final textMsg = msg['text']?.toString() ?? ''; + if (textMsg.startsWith("📋 *Pengajuan Izin*")) { + return _buildIzinCard(textMsg, widget.receiverName, _formatTime(msg['created_at']), isMe); + } + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: Row( + children: [ + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -120), + onSelected: (value) { + if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + ], + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildIzinCard(String text, String senderName, String time, bool isMe) { + final lines = text.split('\n'); + String nama = ''; + String tanggal = ''; + String keterangan = ''; + List lampiranUrls = []; + String idIzin = ''; + String jenis = ''; + + for (var rawLine in lines) { + final line = rawLine.trim(); + if (line.startsWith('Nama: ')) nama = line.substring(6).trim(); + else if (line.startsWith('Tanggal: ')) tanggal = line.substring(9).trim(); + else if (line.startsWith('Keterangan: ')) keterangan = line.substring(12).trim(); + else if (line.startsWith('Lampiran: ')) { + final urlsString = line.substring(10).trim(); + if (urlsString.isNotEmpty) { + lampiranUrls = urlsString.split(RegExp(r'\s+')).where((s) => s.startsWith('http')).toList(); + } + } + else if (line.startsWith('ID_IZIN: ')) idIzin = line.substring(8).trim(); + else if (line.startsWith('Jenis: ')) jenis = line.substring(7).trim(); + else if (keterangan.isNotEmpty && lampiranUrls.isEmpty && !line.startsWith('📋') && !line.startsWith('ID_IZIN:') && !line.startsWith('Jenis:')) { + keterangan += '\n$line'; + } + } + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.85, + ), + child: Card( + color: Colors.amber[50], + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + jenis.toLowerCase() == 'sakit' ? Icons.sick : Icons.assignment_ind, + color: jenis.toLowerCase() == 'sakit' ? Colors.red : Colors.amber, + size: 28, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + jenis.isNotEmpty ? 'Pengajuan $jenis' : 'Pengajuan Izin', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ], + ), + const Divider(), + Row( + children: [ + const Icon(Icons.person, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + nama, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black87), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.date_range, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + tanggal, + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ], + ), + if (keterangan.isNotEmpty) ...[ + const SizedBox(height: 8), + const Text('Keterangan:', style: TextStyle(fontSize: 12, color: Colors.grey)), + Text(keterangan, style: const TextStyle(fontSize: 14)), + ], + if (lampiranUrls.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: lampiranUrls.map((urlStr) { + final uri = Uri.tryParse(urlStr); + String extension = 'file'; + String cleanName = 'Lampiran'; + if (uri != null && uri.pathSegments.isNotEmpty) { + final fullName = Uri.decodeComponent(uri.pathSegments.last); + cleanName = fullName.replaceFirst(RegExp(r'^\d{13}_(?:camera_)?'), ''); + final extIdx = fullName.lastIndexOf('.'); + if (extIdx != -1 && extIdx < fullName.length - 1) { + extension = fullName.substring(extIdx + 1).toLowerCase(); + } + } + final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); + final extLabel = extension.length > 4 + ? extension.substring(0, 4).toUpperCase() + : extension.toUpperCase(); + + if (isImage) { + return GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute( + builder: (_) => DocImagePreviewPage(imageUrl: urlStr), + )); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + urlStr, + width: 110, + height: 110, + fit: BoxFit.cover, + loadingBuilder: (ctx, child, progress) => + progress == null ? child : const SizedBox(width: 110, height: 110, child: Center(child: CircularProgressIndicator(strokeWidth: 2))), + errorBuilder: (ctx, _, __) => const SizedBox(width: 110, height: 110, child: Icon(Icons.broken_image)), + ), + ), + ); + } else { + return GestureDetector( + onTap: () async { + if (uri != null) { + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tidak dapat membuka lampiran')), + ); + } + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 220), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.85), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.black12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extLabel, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cleanName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + '$extLabel • Dokumen', + style: const TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ), + ), + ], + ), + ), + ); + } + }).toList(), + ), + ], + if (idIzin.isNotEmpty) ...[ + const Divider(), + IzinStatusWidget(idIzin: idIzin, isTeacher: false, textPesan: text), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.bottomRight, + child: Text( + time, + style: const TextStyle(fontSize: 10, color: Colors.black54), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class IzinStatusWidget extends StatefulWidget { + final String idIzin; + final bool isTeacher; + final String? textPesan; + const IzinStatusWidget({super.key, required this.idIzin, required this.isTeacher, this.textPesan}); + + @override + State createState() => _IzinStatusWidgetState(); +} + +class _IzinStatusWidgetState extends State { + bool? _verifStatus; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchStatus(); + } + + Future _fetchStatus() async { + if (widget.idIzin.isEmpty) { + setState(() => _isLoading = false); + return; + } + try { + final res = await Supabase.instance.client + .from('leave_request') + .select('verif') + .eq('id_tabel', widget.idIzin) + .maybeSingle(); + if (res != null && mounted) { + setState(() { + _verifStatus = res['verif']; + _isLoading = false; + }); + } else { + setState(() => _isLoading = false); + } + } catch (e) { + setState(() => _isLoading = false); + } + } + + Future _updateStatus(bool status) async { + setState(() => _isLoading = true); + try { + await Supabase.instance.client + .from('leave_request') + .update({'verif': status}) + .eq('id_tabel', widget.idIzin); + + if (mounted) { + setState(() { + _verifStatus = status; + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(status ? 'Pengajuan izin disetujui!' : 'Pengajuan izin ditolak!'), + backgroundColor: status ? Colors.green : Colors.red, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal memperbarui status: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + if (_verifStatus == true) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: Color(0xFF059669), size: 16), + SizedBox(width: 6), + Text( + 'Disetujui', + style: TextStyle(color: Color(0xFF059669), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } + + if (_verifStatus == false) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cancel, color: Color(0xFFDC2626), size: 16), + SizedBox(width: 6), + Text( + 'Ditolak', + style: TextStyle(color: Color(0xFFDC2626), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } + + // Status: Pending (null) + if (widget.isTeacher) { + return Container( + margin: const EdgeInsets.only(top: 12), + child: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _updateStatus(true), + icon: const Icon(Icons.check, size: 16, color: Colors.white), + label: const Text('Setujui', style: TextStyle(color: Colors.white, fontFamily: 'Inter', fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF059669), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: () => _updateStatus(false), + icon: const Icon(Icons.close, size: 16, color: Color(0xFFDC2626)), + label: const Text('Tolak', style: TextStyle(color: Color(0xFFDC2626), fontFamily: 'Inter', fontWeight: FontWeight.bold)), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFDC2626)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ), + ); + } + + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.hourglass_empty, color: Color(0xFFD97706), size: 16), + SizedBox(width: 6), + Text( + 'Menunggu Persetujuan', + style: TextStyle(color: Color(0xFFD97706), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/dash_pesan.dart b/lib/dashboard/d_c_murid/function/f_Pesan/dash_pesan.dart new file mode 100644 index 0000000..8961580 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/dash_pesan.dart @@ -0,0 +1,428 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/grup_sekolah.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/grup_kelas.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/chat_admin.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/chat_private.dart'; +import 'package:apk/dashboard/d_c_murid/widget/custom_button.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_guru.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/grup_sekolah_n.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/grup_kelas_n.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/chat_private_n.dart'; + +class DashPesan extends StatefulWidget { + const DashPesan({super.key}); + + @override + State createState() => _DashPesanState(); +} + +class _DashPesanState extends State { + String? _adminId; + + @override + void initState() { + super.initState(); + _loadAdminId(); + } + + Future _loadAdminId() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() { + _adminId = prefs.getString('user_id_admin'); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + titleSpacing: 0, + title: const Text('Pilih Grup Chat'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: FutureBuilder>>( + future: DbGuru().getGuruForStudentClass(), + builder: (context, snapshot) { + final gurus = snapshot.data ?? []; + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 10.0), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder( + valueListenable: GrupSekolahNotification.hasUnread, + builder: (context, hasUnread, child) { + return Stack( + children: [ + CustomCard( + title: 'Grup Sekolah', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Forum', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Informasi & Diskusi', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + GrupSekolahNotification.setUnread(false); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GrupSekolah()), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder( + valueListenable: GrupKelasNotification.hasUnread, + builder: (context, hasUnread, child) { + return Stack( + children: [ + CustomCard( + title: 'Grup Kelas', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Kelas', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Diskusi Khusus Kelas Anda', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + GrupKelasNotification.setUnread(false); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GrupKelasPage()), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder>( + valueListenable: ChatPrivateNotification.unreadSenderIds, + builder: (context, unreadSenders, child) { + final bool hasUnread = _adminId != null && unreadSenders.contains(_adminId); + return Stack( + children: [ + CustomCard( + title: 'Admin Sekolah', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Bantuan', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Hubungi Admin Sekolah', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + if (_adminId != null) { + ChatPrivateNotification.setSenderUnread(_adminId!, false); + } + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ChatAdminPage()), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ), + if (gurus.isNotEmpty) ...[ + const SizedBox(height: 32), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'Pesan ke Guru', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + ), + const SizedBox(height: 16), + ...gurus.map((guru) { + final String nama = guru['name'] ?? 'Guru'; + final String nip = guru['nik']?.toString() ?? '-'; + final String bidang = (guru['bidang']?.toString().isEmpty ?? true) + ? 'Guru' + : guru['bidang'].toString(); + final String guruId = guru['id_tabel'].toString(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder>( + valueListenable: ChatPrivateNotification.unreadSenderIds, + builder: (context, unreadSenders, child) { + final bool hasUnread = unreadSenders.contains(guruId); + return Stack( + children: [ + CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + "NIP: $nip", + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + bidang, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + ChatPrivateNotification.setSenderUnread(guruId, false); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPrivate( + receiverId: guruId, + receiverType: 'guru', + receiverName: nama, + ), + ), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ); + }).toList(), + ], + ], + ); + }, + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/doc_image.dart b/lib/dashboard/d_c_murid/function/f_Pesan/doc_image.dart new file mode 100644 index 0000000..9b1381d --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/doc_image.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class DocImagePreviewPage extends StatelessWidget { + final String imageUrl; + + const DocImagePreviewPage({super.key, required this.imageUrl}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Pratinjau Gambar"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: Center( + child: InteractiveViewer( + child: Image.network( + imageUrl, + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center(child: CircularProgressIndicator(color: Colors.white)); + }, + errorBuilder: (context, error, stackTrace) => const Icon(Icons.broken_image, color: Colors.white, size: 50), + ), + ), + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/grup_kelas.dart b/lib/dashboard/d_c_murid/function/f_Pesan/grup_kelas.dart new file mode 100644 index 0000000..d8afcce --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/grup_kelas.dart @@ -0,0 +1,906 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_grup_kelas.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/member_kelas.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:apk/dashboard/d_c_murid/database/storage/image_doc.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/doc_image.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/pengumuman_card.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/grup_kelas_n.dart'; + +class GrupKelasPage extends StatefulWidget { + const GrupKelasPage({super.key}); + + @override + State createState() => _GrupKelasPageState(); +} + +class _GrupKelasPageState extends State { + final TextEditingController _messageController = TextEditingController(); + + bool _isLoading = true; + String? _errorMessage; + + String? _currentUserId; + String? _currentUserRole; // 'admin', 'guru', or 'murid' + String? _currentClassId; + + Stream>>? _chatStream; + Map _userNames = {}; // mapping ID ke Nama + + @override + void initState() { + super.initState(); + _initChat(); + GrupKelasNotification.setUnread(false); + } + + Future _initChat() async { + try { + // Fetch mapping nama semua user + _userNames = await DbGrupKelas.fetchAllNames(); + + // Fetch current user info + final currentUserInfo = await DbGrupKelas.getCurrentUserRoleAndId(); + + if (currentUserInfo != null) { + _currentUserId = currentUserInfo['id']; + _currentUserRole = currentUserInfo['role']; + _currentClassId = currentUserInfo['id_class']; + + if (_currentUserRole == 'admin') { + _errorMessage = 'Admin tidak tergabung secara default dalam satu grup kelas spesifik.'; + } else if (_currentClassId == null || _currentClassId!.isEmpty) { + _errorMessage = 'Anda belum terdaftar di kelas manapun.'; + } else { + _chatStream = DbGrupKelas().getGrupKelasStream(_currentClassId!); + } + } else { + _errorMessage = 'Gagal memuat profil. Pastikan Anda telah login dengan benar.'; + } + } catch (e) { + _errorMessage = 'Error: $e'; + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + await DbGrupKelas().sendMessage( + text: 'IMAGE_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + idClass: _currentClassId!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + await DbGrupKelas().sendMessage( + text: 'DOC_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + idClass: _currentClassId!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _currentUserId == null || _currentUserRole == null || _currentClassId == null) return; + + _messageController.clear(); + + try { + await DbGrupKelas().sendMessage( + text: text, + senderId: _currentUserId!, + role: _currentUserRole!, + idClass: _currentClassId!, + ); + + // Reload stream dari database setelah mengirim pesan + if (mounted) { + setState(() { + _isLoading = true; + }); + await _initChat(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + String _getSenderId(Map msg) { + if (msg['pengirim_admin'] != null) return msg['pengirim_admin'].toString(); + if (msg['pengirim_guru'] != null) return msg['pengirim_guru'].toString(); + if (msg['pengirim_murid'] != null) return msg['pengirim_murid'].toString(); + return ''; + } + + Widget _buildIzinCard(String text, String senderName, String time, bool isMe) { + final lines = text.split('\n'); + String nama = ''; + String tanggal = ''; + String keterangan = ''; + List lampiranUrls = []; + String idIzin = ''; + String jenis = ''; + + for (var rawLine in lines) { + final line = rawLine.trim(); + if (line.startsWith('Nama: ')) nama = line.substring(6).trim(); + else if (line.startsWith('Tanggal: ')) tanggal = line.substring(9).trim(); + else if (line.startsWith('Keterangan: ')) keterangan = line.substring(12).trim(); + else if (line.startsWith('Lampiran: ')) { + final urlsString = line.substring(10).trim(); + if (urlsString.isNotEmpty) { + lampiranUrls = urlsString.split(RegExp(r'\s+')).where((s) => s.startsWith('http')).toList(); + } + } + else if (line.startsWith('ID_IZIN: ')) idIzin = line.substring(8).trim(); + else if (line.startsWith('Jenis: ')) jenis = line.substring(7).trim(); + else if (keterangan.isNotEmpty && lampiranUrls.isEmpty && !line.startsWith('📋') && !line.startsWith('ID_IZIN:') && !line.startsWith('Jenis:')) { + keterangan += '\n$line'; + } + } + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.85, + ), + child: Card( + color: Colors.amber[50], + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + jenis.toLowerCase() == 'sakit' ? Icons.sick : Icons.assignment_ind, + color: jenis.toLowerCase() == 'sakit' ? Colors.red : Colors.amber, + size: 28, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + jenis.isNotEmpty ? 'Pengajuan $jenis' : 'Pengajuan Izin', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ], + ), + const Divider(), + Row( + children: [ + const Icon(Icons.person, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + nama, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black87), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.date_range, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + tanggal, + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ], + ), + if (keterangan.isNotEmpty) ...[ + const SizedBox(height: 8), + const Text('Keterangan:', style: TextStyle(fontSize: 12, color: Colors.grey)), + Text(keterangan, style: const TextStyle(fontSize: 14)), + ], + if (lampiranUrls.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: lampiranUrls.map((urlStr) { + final uri = Uri.tryParse(urlStr); + String extension = 'file'; + String cleanName = 'Lampiran'; + if (uri != null && uri.pathSegments.isNotEmpty) { + final fullName = Uri.decodeComponent(uri.pathSegments.last); + cleanName = fullName.replaceFirst(RegExp(r'^\d{13}_(?:camera_)?'), ''); + final extIdx = fullName.lastIndexOf('.'); + if (extIdx != -1 && extIdx < fullName.length - 1) { + extension = fullName.substring(extIdx + 1).toLowerCase(); + } + } + final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); + final extLabel = extension.length > 4 + ? extension.substring(0, 4).toUpperCase() + : extension.toUpperCase(); + + if (isImage) { + return GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute( + builder: (_) => DocImagePreviewPage(imageUrl: urlStr), + )); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + urlStr, + width: 110, + height: 110, + fit: BoxFit.cover, + loadingBuilder: (ctx, child, progress) => + progress == null ? child : const SizedBox(width: 110, height: 110, child: Center(child: CircularProgressIndicator(strokeWidth: 2))), + errorBuilder: (ctx, _, __) => const SizedBox(width: 110, height: 110, child: Icon(Icons.broken_image)), + ), + ), + ); + } else { + return GestureDetector( + onTap: () async { + if (uri != null) { + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tidak dapat membuka lampiran')), + ); + } + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 220), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.85), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.black12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extLabel, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cleanName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + '$extLabel • Dokumen', + style: const TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ), + ), + ], + ), + ), + ); + } + }).toList(), + ), + ], + if (idIzin.isNotEmpty) ...[ + const Divider(), + IzinStatusWidget(idIzin: idIzin, isTeacher: false), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.bottomRight, + child: Text( + time, + style: const TextStyle(fontSize: 10, color: Colors.black54), + ), + ), + ], + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Grup Kelas"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + actions: [ + IconButton( + icon: const Icon(Icons.people), + tooltip: 'Anggota Kelas', + onPressed: () { + if (_currentClassId != null && _currentClassId!.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MemberKelasPage(classId: _currentClassId!), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Anda belum tergabung di kelas mana pun')), + ); + } + }, + ) + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center(child: Padding(padding: const EdgeInsets.all(16), child: Text(_errorMessage!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.red)))) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center(child: Text('Belum ada pesan di kelas ini')); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + final senderId = _getSenderId(msg); + final isMe = senderId == _currentUserId; + final senderName = isMe ? 'Anda' : (_userNames[senderId] ?? 'Tidak diketahui'); + + final textMsg = msg['text'] ?? ''; + if (textMsg.startsWith("📋 *Pengajuan Izin*")) { + return _buildIzinCard(textMsg, senderName, _formatTime(msg['created_at']), isMe); + } + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (!isMe) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + senderName, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF2563EB), + ), + ), + ), + if (msg['text']?.toString().startsWith('PENGUMUMAN_ID:') == true) + PengumumanCard(pengumumanId: msg['text'].toString().split(':')[1]) + else if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -120), + onSelected: (value) { + if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + ], + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan kelas...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class IzinStatusWidget extends StatefulWidget { + final String idIzin; + final bool isTeacher; + const IzinStatusWidget({super.key, required this.idIzin, required this.isTeacher}); + + @override + State createState() => _IzinStatusWidgetState(); +} + +class _IzinStatusWidgetState extends State { + bool? _verifStatus; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchStatus(); + } + + Future _fetchStatus() async { + if (widget.idIzin.isEmpty) { + setState(() => _isLoading = false); + return; + } + try { + final res = await Supabase.instance.client + .from('leave_request') + .select('verif') + .eq('id_tabel', widget.idIzin) + .maybeSingle(); + if (res != null && mounted) { + setState(() { + _verifStatus = res['verif']; + _isLoading = false; + }); + } else { + setState(() => _isLoading = false); + } + } catch (e) { + setState(() => _isLoading = false); + } + } + + Future _updateStatus(bool status) async { + setState(() => _isLoading = true); + try { + await Supabase.instance.client + .from('leave_request') + .update({'verif': status}) + .eq('id_tabel', widget.idIzin); + + if (mounted) { + setState(() { + _verifStatus = status; + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(status ? 'Pengajuan izin disetujui!' : 'Pengajuan izin ditolak!'), + backgroundColor: status ? Colors.green : Colors.red, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal memperbarui status: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + if (_verifStatus == true) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: Color(0xFF059669), size: 16), + SizedBox(width: 6), + Text( + 'Disetujui', + style: TextStyle(color: Color(0xFF059669), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } + + if (_verifStatus == false) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cancel, color: Color(0xFFDC2626), size: 16), + SizedBox(width: 6), + Text( + 'Ditolak', + style: TextStyle(color: Color(0xFFDC2626), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } + + // Status: Pending (null) + if (widget.isTeacher) { + return Container( + margin: const EdgeInsets.only(top: 12), + child: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _updateStatus(true), + icon: const Icon(Icons.check, size: 16, color: Colors.white), + label: const Text('Setujui', style: TextStyle(color: Colors.white, fontFamily: 'Inter', fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF059669), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: () => _updateStatus(false), + icon: const Icon(Icons.close, size: 16, color: Color(0xFFDC2626)), + label: const Text('Tolak', style: TextStyle(color: Color(0xFFDC2626), fontFamily: 'Inter', fontWeight: FontWeight.bold)), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFDC2626)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ), + ); + } + + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.hourglass_empty, color: Color(0xFFD97706), size: 16), + SizedBox(width: 6), + Text( + 'Menunggu Persetujuan', + style: TextStyle(color: Color(0xFFD97706), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } +} + diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/grup_sekolah.dart b/lib/dashboard/d_c_murid/function/f_Pesan/grup_sekolah.dart new file mode 100644 index 0000000..56a8a73 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/grup_sekolah.dart @@ -0,0 +1,475 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_grup_sekolah.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/member_sekolah.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/pengumuman_card.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/d_c_murid/database/storage/image_doc.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/doc_image.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/grup_sekolah_n.dart'; + +class GrupSekolah extends StatefulWidget { + const GrupSekolah({super.key}); + + @override + State createState() => _GrupSekolahState(); +} + +class _GrupSekolahState extends State { + final TextEditingController _messageController = TextEditingController(); + + bool _isLoading = true; + String? _errorMessage; + + String? _currentUserId; + String? _currentUserRole; // 'admin', 'guru', or 'murid' + + Stream>>? _chatStream; + Map _userNames = {}; // mapping ID ke Nama + + @override + void initState() { + super.initState(); + _initChat(); + GrupSekolahNotification.setUnread(false); + } + + Future _initChat() async { + try { + // Fetch mapping nama semua user + _userNames = await DbGrupSekolah.fetchAllNames(); + + // Fetch current user info + final currentUserInfo = await DbGrupSekolah.getCurrentUserRoleAndId(); + + if (currentUserInfo != null) { + _currentUserId = currentUserInfo['id']; + _currentUserRole = currentUserInfo['role']; + _chatStream = DbGrupSekolah().getGrupSekolahStream(); + } else { + _errorMessage = 'Gagal memuat profil. Pastikan Anda telah login dengan benar.'; + } + } catch (e) { + _errorMessage = 'Error: $e'; + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + await DbGrupSekolah().sendMessage( + text: 'IMAGE_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + await DbGrupSekolah().sendMessage( + text: 'DOC_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _currentUserId == null || _currentUserRole == null) return; + + _messageController.clear(); + + try { + await DbGrupSekolah().sendMessage( + text: text, + senderId: _currentUserId!, + role: _currentUserRole!, + ); + + // Reload stream dari database setelah mengirim pesan + if (mounted) { + setState(() { + _isLoading = true; + }); + await _initChat(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + String _getSenderId(Map msg) { + if (msg['pengirim_admin'] != null) return msg['pengirim_admin'].toString(); + if (msg['pengirim_guru'] != null) return msg['pengirim_guru'].toString(); + if (msg['pengirim_murid'] != null) return msg['pengirim_murid'].toString(); + return ''; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Grup Sekolah"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + actions: [ + IconButton( + icon: const Icon(Icons.people), + tooltip: 'Anggota Grup', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => MemberSekolahPage()), + ); + }, + ) + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center(child: Padding(padding: const EdgeInsets.all(16), child: Text(_errorMessage!, style: const TextStyle(color: Colors.red)))) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center(child: Text('Belum ada pesan. Mulai obrolan!')); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + bool isMe = false; + if (_currentUserRole == 'admin') { + isMe = msg['pengirim_admin']?.toString() == _currentUserId; + } else if (_currentUserRole == 'guru') { + isMe = msg['pengirim_guru']?.toString() == _currentUserId; + } else if (_currentUserRole == 'murid') { + isMe = msg['pengirim_murid']?.toString() == _currentUserId; + } + final senderId = _getSenderId(msg); + final senderName = _userNames[senderId] ?? 'Pengguna'; + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (!isMe) + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 2), + child: Text( + senderName, + style: const TextStyle( + fontSize: 12, + color: Colors.black54, + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (msg['text']?.toString().startsWith('PENGUMUMAN_ID:') == true) + PengumumanCard(pengumumanId: msg['text'].toString().split(':')[1]) + else if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: Row( + children: [ + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -120), + onSelected: (value) { + if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + ], + ), + + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/grup_sekolah_p.dart b/lib/dashboard/d_c_murid/function/f_Pesan/grup_sekolah_p.dart new file mode 100644 index 0000000..1694933 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/grup_sekolah_p.dart @@ -0,0 +1 @@ +// Deprecated. Fungsi notifikasi dipindahkan ke lib/function/f_Pesan/notif_pesan/preview_pesan.dart diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/member_kelas.dart b/lib/dashboard/d_c_murid/function/f_Pesan/member_kelas.dart new file mode 100644 index 0000000..7f93c43 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/member_kelas.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_guru.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_murid.dart'; + +class MemberKelasPage extends StatefulWidget { + final String classId; + const MemberKelasPage({super.key, required this.classId}); + + @override + State createState() => _MemberKelasPageState(); +} + +class _MemberKelasPageState extends State { + final DbGuru _dbGuru = DbGuru(); + final DbMurid _dbMurid = DbMurid(); + + bool _isLoading = true; + String? _errorMessage; + + List> _gurusInClass = []; + List> _muridsInClass = []; + String _className = 'Kelas'; + + @override + void initState() { + super.initState(); + _fetchMembers(); + } + + Future _fetchMembers() async { + try { + final guruList = await _dbGuru.getAllGuru(); + final muridList = await _dbMurid.getAllMurid(); + + final List> gurus = []; + final List> murids = []; + + // Ambil nama kelas untuk judul + final supabase = Supabase.instance.client; + final classInfo = await supabase.from('class_name').select('name_class').eq('id_tabel', widget.classId).maybeSingle(); + if (classInfo?['name_class'] != null) { + _className = classInfo!['name_class'].toString(); + } + + // Process Guru (Cek id_class dan boolean 'wali') + for (var guru in guruList) { + if (guru['id_class']?.toString() == widget.classId) { + if (guru['wali'] == true) { + guru['role'] = 'Guru / Wali Kelas'; + } else { + guru['role'] = 'Guru'; + } + gurus.add(guru); + } + } + + // Process Murid + for (var murid in muridList) { + if (murid['id_class']?.toString() == widget.classId) { + murid['role'] = 'Murid'; + murids.add(murid); + } + } + + setState(() { + _gurusInClass = gurus; + _muridsInClass = murids; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Gagal memuat anggota kelas: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: Text("Anggota $_className"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontFamily: 'Inter')), + ), + ) + : _buildMemberList(), + ); + } + + Widget _buildMemberList() { + if (_gurusInClass.isEmpty && _muridsInClass.isEmpty) { + return const Center(child: Text('Belum ada anggota di kelas ini.', style: TextStyle(fontFamily: 'Inter'))); + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (_gurusInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Guru:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ..._gurusInClass.map((g) { + final name = g['name'] ?? '-'; + + return ListTile( + leading: const CircleAvatar( + backgroundColor: Color(0xFF2563EB), + child: Icon(Icons.person, color: Colors.white), + ), + title: Text(name, style: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w500)), + subtitle: Text(g['role'] ?? 'Guru', style: const TextStyle(fontFamily: 'Inter', fontSize: 13)), + ); + }).toList(), + + if (_muridsInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Murid:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ..._muridsInClass.map((m) { + final name = m['nama'] ?? '-'; + + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.orange[400], + child: const Icon(Icons.person_outline, color: Colors.white), + ), + title: Text(name, style: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w500)), + subtitle: const Text('Murid', style: TextStyle(fontFamily: 'Inter', fontSize: 13)), + ); + }).toList(), + ], + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/member_sekolah.dart b/lib/dashboard/d_c_murid/function/f_Pesan/member_sekolah.dart new file mode 100644 index 0000000..b47a39d --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/member_sekolah.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_guru.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_murid.dart'; + +class MemberSekolahPage extends StatefulWidget { + const MemberSekolahPage({super.key}); + + @override + State createState() => _MemberSekolahPageState(); +} + +class _MemberSekolahPageState extends State { + bool _isLoading = true; + List> _listClass = []; + List> _listGuru = []; + List> _listMurid = []; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + try { + final classRes = await DbMurid().getKelas(); + final guruRes = await DbGuru().getAllGuru(); + final muridRes = await DbMurid().getAllMurid(); + + if (mounted) { + setState(() { + _listClass = classRes; + _listGuru = guruRes; + _listMurid = muridRes; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Anggota Grup Sekolah"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _buildMemberList(), + ); + } + + Widget _buildMemberList() { + // Kita bisa mengelompokkan berdasarkan id_class + // Tambahkan 1 kelas "Tanpa Kelas" jika ada yang id_class nya null + List> allClasses = List.from(_listClass); + allClasses.add({ + 'id_tabel': null, + 'name_class': 'Tanpa Kelas (Belum Dimasukkan Kelas)' + }); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: allClasses.length, + itemBuilder: (context, index) { + final currentClass = allClasses[index]; + final classId = currentClass['id_tabel']; + final className = currentClass['name_class']; + + // Filter Guru dan Murid untuk kelas ini + final gurusInClass = _listGuru + .where((g) => g['id_class'] == classId) + .toList(); + final muridsInClass = _listMurid + .where((m) => m['id_class'] == classId) + .toList(); + + if (gurusInClass.isEmpty && muridsInClass.isEmpty && classId == null) { + return const SizedBox.shrink(); // Jangan tampilkan 'Tanpa Kelas' jika kosong + } + + return ExpansionTile( + title: Text( + className, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + children: [ + if (gurusInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Guru:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ...gurusInClass.map((g) { + final isWali = g['wali'] == true; + return ListTile( + leading: const CircleAvatar( + backgroundColor: Color(0xFF2563EB), + child: Icon(Icons.person, color: Colors.white), + ), + title: Text(g['name'] ?? '-'), + subtitle: Text(isWali ? 'Guru / Wali Kelas' : 'Guru'), + ); + }).toList(), + + if (muridsInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Murid:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ...muridsInClass.map((m) { + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.orange[400], + child: const Icon(Icons.person_outline, color: Colors.white), + ), + title: Text(m['nama'] ?? '-'), + subtitle: const Text('Murid'), + ); + }).toList(), + if (gurusInClass.isEmpty && muridsInClass.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text('Belum ada anggota di kelas ini.'), + ) + ], + ); + }, + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/notif_pesan/preview_pesan.dart b/lib/dashboard/d_c_murid/function/f_Pesan/notif_pesan/preview_pesan.dart new file mode 100644 index 0000000..298af58 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/notif_pesan/preview_pesan.dart @@ -0,0 +1,1205 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_grup_sekolah.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_grup_kelas.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_pesan_private.dart'; + +// Ortu/Murid navigation targets +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/grup_sekolah.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/grup_kelas.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/chat_admin.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/dash_pesan.dart'; +import 'package:apk/dashboard/d_c_murid/function/p_pengumuman.dart/preview_p.dart'; + +class PreviewPesan extends StatefulWidget { + const PreviewPesan({super.key}); + + @override + State createState() => _PreviewPesanState(); +} + +class _PreviewPesanState extends State { + String? _currentUserId; + String? _currentUserRole; + String? _currentClassId; + String? _idAdmin; + bool _isLoading = true; + + final Map _counts = { + 'sekolah': 0, + 'kelas': 0, + 'admin': 0, + 'guru': 0, + 'pengumuman': 0, + }; + + @override + void initState() { + super.initState(); + _initUser(); + } + + Future _initUser() async { + try { + final prefs = await SharedPreferences.getInstance(); + _idAdmin = prefs.getString('user_id_admin'); + + final info = await DbGrupKelas.getCurrentUserRoleAndId(); + if (info != null) { + _currentUserId = info['id']; + _currentUserRole = info['role']; + _currentClassId = info['id_class']; + } + } catch (_) {} + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + void _updateCount(String key, int count) { + if (_counts[key] != count) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _counts[key] = count; + }); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + if (_currentUserId == null || _currentUserRole == null) { + return const Scaffold( + body: Center(child: Text('Harap login terlebih dahulu.')), + ); + } + + final totalUnread = _counts.values.fold(0, (sum, val) => sum + val); + + return Scaffold( + backgroundColor: const Color(0xFFF1F5F9), + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Notifikasi"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: totalUnread == 0 + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.notifications_none_rounded, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'Tidak ada notifikasi baru', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 16, + fontFamily: 'Inter', + ), + ), + ], + ), + ) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text( + 'Pesan & Informasi Masuk', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + fontFamily: 'Inter', + letterSpacing: 0.5, + ), + ), + ), + // ──── KATEGORI NOTIFIKASI ORTU/MURID ──── + // 1. Grup Sekolah + GrupSekolahNotificationCard( + currentUserId: _currentUserId!, + currentUserRole: _currentUserRole!, + onCountChanged: (count) => _updateCount('sekolah', count), + ), + // 2. Grup Kelas + if (_currentClassId != null && _currentClassId!.isNotEmpty) + GrupKelasNotificationCard( + idClass: _currentClassId!, + currentUserId: _currentUserId!, + currentUserRole: _currentUserRole!, + onCountChanged: (count) => _updateCount('kelas', count), + ), + // 3. Admin Sekolah + if (_idAdmin != null) + AdminSekolahNotificationCard( + idMurid: _currentUserId!, + idAdmin: _idAdmin!, + onCountChanged: (count) => _updateCount('admin', count), + ), + // 4. Pesan ke Guru + PesanGuruNotificationCard( + idMurid: _currentUserId!, + onCountChanged: (count) => _updateCount('guru', count), + ), + // 5. Pengumuman (Hanya untuk Ortu/Murid) + PengumumanNotificationCard( + onCountChanged: (count) => _updateCount('pengumuman', count), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// HELPER CARD: ORTU/MURID VERSION +// ───────────────────────────────────────────────────────────────────────────── + +class GrupSekolahNotificationCard extends StatefulWidget { + final String currentUserId; + final String currentUserRole; + final ValueChanged onCountChanged; + + const GrupSekolahNotificationCard({ + super.key, + required this.currentUserId, + required this.currentUserRole, + required this.onCountChanged, + }); + + @override + State createState() => + _GrupSekolahNotificationCardState(); +} + +class _GrupSekolahNotificationCardState + extends State { + DateTime? _lastVisited; + + @override + void initState() { + super.initState(); + _loadLastVisited(); + } + + Future _loadLastVisited() async { + final prefs = await SharedPreferences.getInstance(); + final lastVisitedStr = prefs.getString('last_visited_grup_sekolah'); + if (mounted) { + setState(() { + _lastVisited = + lastVisitedStr != null ? DateTime.tryParse(lastVisitedStr) : null; + }); + } + } + + String _formatTime(String? timeStr) { + if (timeStr == null) return ''; + final dt = DateTime.tryParse(timeStr)?.toLocal(); + if (dt == null) return ''; + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 1) return 'Baru saja'; + if (diff.inHours < 1) return '${diff.inMinutes} menit lalu'; + if (diff.inDays < 1) return '${diff.inHours} jam lalu'; + return '${diff.inDays} hari lalu'; + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>>( + stream: DbGrupSekolah().getGrupSekolahStream(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + widget.onCountChanged(0); + return const SizedBox.shrink(); + } + + int unreadCount = 0; + String? latestMessagePreview; + String? latestMessageTime; + + for (final msg in snapshot.data!) { + final createdAtStr = msg['created_at']?.toString(); + bool isMe = false; + if (widget.currentUserRole == 'admin') { + isMe = msg['pengirim_admin']?.toString() == widget.currentUserId; + } else if (widget.currentUserRole == 'guru') { + isMe = msg['pengirim_guru']?.toString() == widget.currentUserId; + } else if (widget.currentUserRole == 'murid') { + isMe = msg['pengirim_murid']?.toString() == widget.currentUserId; + } + + if (createdAtStr != null && !isMe) { + final createdAt = DateTime.tryParse(createdAtStr); + if (createdAt != null) { + if (_lastVisited == null || createdAt.isAfter(_lastVisited!)) { + unreadCount++; + latestMessagePreview = msg['text']?.toString(); + latestMessageTime = createdAtStr; + } + } + } + } + + widget.onCountChanged(unreadCount); + + if (unreadCount == 0) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 0, + color: Colors.white, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'last_visited_grup_sekolah', DateTime.now().toIso8601String()); + widget.onCountChanged(0); + if (!context.mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const GrupSekolah()), + ); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.groups_rounded, + color: Color(0xFF2563EB), + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Grup Sekolah', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Color(0xFF1E293B), + fontFamily: 'Inter', + ), + ), + Text( + _formatTime(latestMessageTime), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + fontFamily: 'Inter', + ), + ), + ], + ), + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$unreadCount pesan baru', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFDC2626), + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), + ), + ), + if (latestMessagePreview != null) ...[ + const SizedBox(height: 6), + Text( + latestMessagePreview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF64748B), + fontFamily: 'Inter', + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class GrupKelasNotificationCard extends StatefulWidget { + final String idClass; + final String currentUserId; + final String currentUserRole; + final ValueChanged onCountChanged; + + const GrupKelasNotificationCard({ + super.key, + required this.idClass, + required this.currentUserId, + required this.currentUserRole, + required this.onCountChanged, + }); + + @override + State createState() => + _GrupKelasNotificationCardState(); +} + +class _GrupKelasNotificationCardState extends State { + DateTime? _lastVisited; + + @override + void initState() { + super.initState(); + _loadLastVisited(); + } + + Future _loadLastVisited() async { + final prefs = await SharedPreferences.getInstance(); + final lastVisitedStr = prefs.getString('last_visited_grup_kelas'); + if (mounted) { + setState(() { + _lastVisited = + lastVisitedStr != null ? DateTime.tryParse(lastVisitedStr) : null; + }); + } + } + + String _formatTime(String? timeStr) { + if (timeStr == null) return ''; + final dt = DateTime.tryParse(timeStr)?.toLocal(); + if (dt == null) return ''; + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 1) return 'Baru saja'; + if (diff.inHours < 1) return '${diff.inMinutes} menit lalu'; + if (diff.inDays < 1) return '${diff.inHours} jam lalu'; + return '${diff.inDays} hari lalu'; + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>>( + stream: DbGrupKelas().getGrupKelasStream(widget.idClass), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + widget.onCountChanged(0); + return const SizedBox.shrink(); + } + + int unreadCount = 0; + String? latestMessagePreview; + String? latestMessageTime; + + for (final msg in snapshot.data!) { + final createdAtStr = msg['created_at']?.toString(); + bool isMe = false; + if (widget.currentUserRole == 'admin') { + isMe = msg['pengirim_admin']?.toString() == widget.currentUserId; + } else if (widget.currentUserRole == 'guru') { + isMe = msg['pengirim_guru']?.toString() == widget.currentUserId; + } else if (widget.currentUserRole == 'murid') { + isMe = msg['pengirim_murid']?.toString() == widget.currentUserId; + } + + if (createdAtStr != null && !isMe) { + final createdAt = DateTime.tryParse(createdAtStr); + if (createdAt != null) { + if (_lastVisited == null || createdAt.isAfter(_lastVisited!)) { + unreadCount++; + latestMessagePreview = msg['text']?.toString(); + latestMessageTime = createdAtStr; + } + } + } + } + + widget.onCountChanged(unreadCount); + + if (unreadCount == 0) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 0, + color: Colors.white, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'last_visited_grup_kelas', DateTime.now().toIso8601String()); + widget.onCountChanged(0); + if (!context.mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const GrupKelasPage()), + ); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.forum_rounded, + color: Color(0xFF2563EB), + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Grup Kelas', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Color(0xFF1E293B), + fontFamily: 'Inter', + ), + ), + Text( + _formatTime(latestMessageTime), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + fontFamily: 'Inter', + ), + ), + ], + ), + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$unreadCount pesan baru', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFDC2626), + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), + ), + ), + if (latestMessagePreview != null) ...[ + const SizedBox(height: 6), + Text( + latestMessagePreview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF64748B), + fontFamily: 'Inter', + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class AdminSekolahNotificationCard extends StatefulWidget { + final String idMurid; + final String idAdmin; + final ValueChanged onCountChanged; + + const AdminSekolahNotificationCard({ + super.key, + required this.idMurid, + required this.idAdmin, + required this.onCountChanged, + }); + + @override + State createState() => + _AdminSekolahNotificationCardState(); +} + +class _AdminSekolahNotificationCardState + extends State { + DateTime? _lastVisited; + + @override + void initState() { + super.initState(); + _loadLastVisited(); + } + + Future _loadLastVisited() async { + final prefs = await SharedPreferences.getInstance(); + final lastVisitedStr = prefs.getString('last_visited_chat_admin'); + if (mounted) { + setState(() { + _lastVisited = + lastVisitedStr != null ? DateTime.tryParse(lastVisitedStr) : null; + }); + } + } + + String _formatTime(String? timeStr) { + if (timeStr == null) return ''; + final dt = DateTime.tryParse(timeStr)?.toLocal(); + if (dt == null) return ''; + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 1) return 'Baru saja'; + if (diff.inHours < 1) return '${diff.inMinutes} menit lalu'; + if (diff.inDays < 1) return '${diff.inHours} jam lalu'; + return '${diff.inDays} hari lalu'; + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>>( + stream: DbPesanPrivate().getChatWithAdminStream(widget.idMurid, widget.idAdmin), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + widget.onCountChanged(0); + return const SizedBox.shrink(); + } + + int unreadCount = 0; + String? latestMessagePreview; + String? latestMessageTime; + + for (final msg in snapshot.data!) { + final createdAtStr = msg['created_at']?.toString(); + bool isFromAdmin = msg['pengirim_admin'] == widget.idAdmin; + + if (createdAtStr != null && isFromAdmin) { + final createdAt = DateTime.tryParse(createdAtStr); + if (createdAt != null) { + if (_lastVisited == null || createdAt.isAfter(_lastVisited!)) { + unreadCount++; + latestMessagePreview = msg['text']?.toString(); + latestMessageTime = createdAtStr; + } + } + } + } + + widget.onCountChanged(unreadCount); + + if (unreadCount == 0) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 0, + color: Colors.white, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'last_visited_chat_admin', DateTime.now().toIso8601String()); + widget.onCountChanged(0); + if (!context.mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const ChatAdminPage()), + ); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.support_agent_rounded, + color: Color(0xFF2563EB), + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Admin Sekolah', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Color(0xFF1E293B), + fontFamily: 'Inter', + ), + ), + Text( + _formatTime(latestMessageTime), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + fontFamily: 'Inter', + ), + ), + ], + ), + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$unreadCount pesan baru', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFDC2626), + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), + ), + ), + if (latestMessagePreview != null) ...[ + const SizedBox(height: 6), + Text( + latestMessagePreview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF64748B), + fontFamily: 'Inter', + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class PesanGuruNotificationCard extends StatefulWidget { + final String idMurid; + final ValueChanged onCountChanged; + + const PesanGuruNotificationCard({ + super.key, + required this.idMurid, + required this.onCountChanged, + }); + + @override + State createState() => + _PesanGuruNotificationCardState(); +} + +class _PesanGuruNotificationCardState extends State { + DateTime? _lastVisited; + + @override + void initState() { + super.initState(); + _loadLastVisited(); + } + + Future _loadLastVisited() async { + final prefs = await SharedPreferences.getInstance(); + final lastVisitedStr = prefs.getString('last_visited_chat_private'); + if (mounted) { + setState(() { + _lastVisited = + lastVisitedStr != null ? DateTime.tryParse(lastVisitedStr) : null; + }); + } + } + + String _formatTime(String? timeStr) { + if (timeStr == null) return ''; + final dt = DateTime.tryParse(timeStr)?.toLocal(); + if (dt == null) return ''; + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 1) return 'Baru saja'; + if (diff.inHours < 1) return '${diff.inMinutes} menit lalu'; + if (diff.inDays < 1) return '${diff.inHours} jam lalu'; + return '${diff.inDays} hari lalu'; + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>>( + stream: Supabase.instance.client + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + e['penerima_murid']?.toString() == widget.idMurid && + e['pengirim_guru'] != null) + .toList()), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + widget.onCountChanged(0); + return const SizedBox.shrink(); + } + + int unreadCount = 0; + String? latestMessagePreview; + String? latestMessageTime; + + for (final msg in snapshot.data!) { + final createdAtStr = msg['created_at']?.toString(); + + if (createdAtStr != null) { + final createdAt = DateTime.tryParse(createdAtStr); + if (createdAt != null) { + if (_lastVisited == null || createdAt.isAfter(_lastVisited!)) { + unreadCount++; + latestMessagePreview = msg['text']?.toString(); + latestMessageTime = createdAtStr; + } + } + } + } + + widget.onCountChanged(unreadCount); + + if (unreadCount == 0) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 0, + color: Colors.white, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'last_visited_chat_private', DateTime.now().toIso8601String()); + widget.onCountChanged(0); + if (!context.mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const DashPesan()), + ); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.chat_bubble_rounded, + color: Color(0xFF2563EB), + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Pesan dari Guru', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Color(0xFF1E293B), + fontFamily: 'Inter', + ), + ), + Text( + _formatTime(latestMessageTime), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + fontFamily: 'Inter', + ), + ), + ], + ), + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$unreadCount pesan baru', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFDC2626), + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), + ), + ), + if (latestMessagePreview != null) ...[ + const SizedBox(height: 6), + Text( + latestMessagePreview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF64748B), + fontFamily: 'Inter', + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class PengumumanNotificationCard extends StatefulWidget { + final ValueChanged onCountChanged; + + const PengumumanNotificationCard({ + super.key, + required this.onCountChanged, + }); + + @override + State createState() => + _PengumumanNotificationCardState(); +} + +class _PengumumanNotificationCardState + extends State { + DateTime? _lastVisited; + + @override + void initState() { + super.initState(); + _loadLastVisited(); + } + + Future _loadLastVisited() async { + final prefs = await SharedPreferences.getInstance(); + final lastVisitedStr = prefs.getString('last_visited_pengumuman'); + if (mounted) { + setState(() { + _lastVisited = + lastVisitedStr != null ? DateTime.tryParse(lastVisitedStr) : null; + }); + } + } + + String _formatTime(String? timeStr) { + if (timeStr == null) return ''; + final dt = DateTime.tryParse(timeStr)?.toLocal(); + if (dt == null) return ''; + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 1) return 'Baru saja'; + if (diff.inHours < 1) return '${diff.inMinutes} menit lalu'; + if (diff.inDays < 1) return '${diff.inHours} jam lalu'; + return '${diff.inDays} hari lalu'; + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>>( + stream: Supabase.instance.client + .from('pengumuman') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + widget.onCountChanged(0); + return const SizedBox.shrink(); + } + + int unreadCount = 0; + String? latestTitle; + String? latestTime; + + for (final item in snapshot.data!) { + final createdAtStr = item['created_at']?.toString(); + + if (createdAtStr != null) { + final createdAt = DateTime.tryParse(createdAtStr); + if (createdAt != null) { + if (_lastVisited == null || createdAt.isAfter(_lastVisited!)) { + unreadCount++; + latestTitle = item['title']?.toString(); + latestTime = createdAtStr; + } + } + } + } + + widget.onCountChanged(unreadCount); + + if (unreadCount == 0) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 0, + color: Colors.white, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'last_visited_pengumuman', DateTime.now().toIso8601String()); + widget.onCountChanged(0); + if (!context.mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const PreviewPengumuman()), + ); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.campaign_rounded, + color: Color(0xFF2563EB), + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Pengumuman Baru', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Color(0xFF1E293B), + fontFamily: 'Inter', + ), + ), + Text( + _formatTime(latestTime), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + fontFamily: 'Inter', + ), + ), + ], + ), + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$unreadCount pengumuman baru', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFDC2626), + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), + ), + ), + if (latestTitle != null) ...[ + const SizedBox(height: 6), + Text( + latestTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF64748B), + fontFamily: 'Inter', + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + + diff --git a/lib/dashboard/d_c_murid/function/f_Pesan/pengumuman_card.dart b/lib/dashboard/d_c_murid/function/f_Pesan/pengumuman_card.dart new file mode 100644 index 0000000..2a91012 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_Pesan/pengumuman_card.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_pengumuman.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/doc_image.dart'; + +class PengumumanCard extends StatelessWidget { + final String pengumumanId; + const PengumumanCard({super.key, required this.pengumumanId}); + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: DbPengumuman.getPengumumanById(pengumumanId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + height: 50, + width: 50, + child: Center(child: CircularProgressIndicator()), + ), + ), + ); + } + if (!snapshot.hasData || snapshot.data == null) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('Pengumuman tidak ditemukan'), + ), + ); + } + + final data = snapshot.data!; + + // Memisahkan keterangan dan URL lampiran jika ada + String textKeterangan = data['keterangan'] ?? ''; + List lampiranUrls = []; + + if (textKeterangan.contains('Lampiran: ')) { + final parts = textKeterangan.split('Lampiran: '); + textKeterangan = parts[0].trim(); + final urlsString = parts.length > 1 ? parts[1].trim() : ''; + lampiranUrls = urlsString.split(RegExp(r'\s+')).where((s) => s.startsWith('http')).toList(); + } + + return Card( + color: Colors.amber[50], + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.campaign, color: Colors.amber, size: 28), + const SizedBox(width: 8), + Expanded( + child: Text( + data['title'] ?? 'Pengumuman', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ], + ), + const Divider(), + Row( + children: [ + const Icon(Icons.date_range, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + '${data['tanggal_mulai']} s/d ${data['tanggal_selesai']}', + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ], + ), + if (textKeterangan.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(textKeterangan, style: const TextStyle(fontSize: 14)), + ], + if (lampiranUrls.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: lampiranUrls.map((lampiranUrl) { + return Builder( + builder: (context) { + final uri = Uri.tryParse(lampiranUrl); + String extension = 'FILE'; + String fileName = 'Lampiran'; + if (uri != null && uri.pathSegments.isNotEmpty) { + final fullName = Uri.decodeComponent(uri.pathSegments.last); + fileName = fullName.replaceFirst(RegExp(r'^\d{13}_'), ''); + final extIndex = fullName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fullName.length - 1) { + extension = fullName.substring(extIndex + 1).toLowerCase(); + } + } + bool isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); + + if (isImage) { + return GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: lampiranUrl))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + lampiranUrl, + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ); + } else { + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(lampiranUrl); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka lampiran'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.8), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.black12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4).toUpperCase() : extension.toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '${extension.toUpperCase()} • Dokumen', + style: const TextStyle( + fontSize: 12, + color: Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + }, + ); + }).toList(), + ) + ] + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/f_izin/form_izin.dart b/lib/dashboard/d_c_murid/function/f_izin/form_izin.dart new file mode 100644 index 0000000..0ddc465 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/f_izin/form_izin.dart @@ -0,0 +1,772 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_izin.dart'; +import 'package:apk/dashboard/d_c_murid/bottom_bar/nav_screen_o.dart'; + +class FormIzin extends StatefulWidget { + const FormIzin({super.key}); + + @override + State createState() => _FormIzinState(); +} + +class _FormIzinState extends State { + final TextEditingController _keteranganController = TextEditingController(); + + DateTime? _startDate; + DateTime? _endDate; + final List _selectedFiles = []; + String _ketOpsi = 'izin'; // 'izin' or 'sakit' + + bool _isLoadingData = true; + bool _isSubmitting = false; + + String? _userIdAdmin; + String? _studentNis; + String? _idMurid; + String? _idGuru; + String? _idClass; + String _studentName = ''; + + @override + void initState() { + super.initState(); + _fetchUserData(); + } + + Future _fetchUserData() async { + try { + final prefs = await SharedPreferences.getInstance(); + _studentNis = prefs.getString('murid_nis'); + _userIdAdmin = prefs.getString('user_id_admin'); + + if (_studentNis != null && _userIdAdmin != null) { + final response = await Supabase.instance.client + .from('murid') + .select() + .eq('user_id', _userIdAdmin!) + .eq('nis', _studentNis!) + .single(); + + String? nameValue; + for (var key in response.keys) { + if (key.toLowerCase().contains('nama') || key.toLowerCase().contains('name')) { + nameValue = response[key].toString(); + break; + } + } + final resolvedStudentName = nameValue ?? 'Tanpa Nama'; + final resolvedIdMurid = response['id_tabel']?.toString(); + final resolvedIdClass = response['id_class']?.toString(); + + String? resolvedIdGuru; + + debugPrint("DIAGNOSTICS: Murid Row fetched from Supabase: $response"); + + if (resolvedIdClass != null) { + try { + final classRes = await Supabase.instance.client + .from('class_name') + .select('id_guru') + .eq('id_tabel', resolvedIdClass) + .maybeSingle(); + debugPrint("DIAGNOSTICS: class_name search result for class ID $resolvedIdClass: $classRes"); + if (classRes != null && classRes['id_guru'] != null) { + resolvedIdGuru = classRes['id_guru']?.toString(); + } + } catch (err) { + debugPrint("Error looking up Wali Kelas from class_name: $err"); + } + } + + if (resolvedIdGuru == null && resolvedIdClass != null) { + try { + final guruRes = await Supabase.instance.client + .from('guru') + .select('id_tabel') + .eq('id_class', resolvedIdClass) + .eq('wali', true) + .maybeSingle(); + debugPrint("DIAGNOSTICS: guru search result for class ID $resolvedIdClass: $guruRes"); + if (guruRes != null) { + resolvedIdGuru = guruRes['id_tabel']?.toString(); + } + } catch (err) { + debugPrint("Error looking up Wali Kelas from guru table: $err"); + } + } + + if (resolvedIdGuru == null) { + debugPrint("DIAGNOSTICS WARNING: Wali kelas is still null. Murid ID: $resolvedIdMurid, Class ID: $resolvedIdClass"); + } + + setState(() { + _studentName = resolvedStudentName; + _idMurid = resolvedIdMurid; + _idGuru = resolvedIdGuru; + _idClass = resolvedIdClass; + _isLoadingData = false; + }); + } else { + setState(() { + _studentName = 'Belum login (NIS tidak ditemukan)'; + _isLoadingData = false; + }); + } + } catch (e) { + debugPrint("DIAGNOSTICS ERROR: _fetchUserData failed with: $e"); + setState(() { + _studentName = 'Error memuat data profil: $e'; + _isLoadingData = false; + }); + } + } + + Future _pickDate(BuildContext context, bool isStart) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now().subtract(const Duration(days: 30)), + lastDate: DateTime.now().add(const Duration(days: 365)), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF2563EB), + ), + ), + child: child!, + ); + }, + ); + + if (picked != null) { + setState(() { + if (isStart) { + _startDate = picked; + if (_endDate != null && _endDate!.isBefore(_startDate!)) { + _endDate = null; + } + } else { + _endDate = picked; + } + }); + } + } + + Future _pickFiles() async { + try { + FilePickerResult? result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'png', 'jpeg', 'pdf'], + allowMultiple: true, + withData: true, + ); + + if (result != null && result.files.isNotEmpty) { + setState(() { + _selectedFiles.addAll(result.files); + }); + } + } catch (e) { + debugPrint("Error picking files: $e"); + } + } + + Future _pickFromCamera() async { + try { + final XFile? photo = await ImagePicker().pickImage( + source: ImageSource.camera, + imageQuality: 85, + ); + + if (photo != null) { + final bytes = await photo.readAsBytes(); + setState(() { + _selectedFiles.add(PlatformFile( + name: photo.name, + size: bytes.length, + bytes: bytes, + path: photo.path, + )); + }); + } + } catch (e) { + debugPrint("Error capturing from camera: $e"); + } + } + + void _showAttachmentOptions() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Wrap( + children: [ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Tambah Lampiran', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined, color: Color(0xFF2563EB)), + title: const Text('Ambil Foto (Kamera)', style: TextStyle(fontFamily: 'Inter')), + onTap: () { + Navigator.pop(context); + _pickFromCamera(); + }, + ), + ListTile( + leading: const Icon(Icons.folder_open_outlined, color: Color(0xFF2563EB)), + title: const Text('Pilih Dokumen / Galeri', style: TextStyle(fontFamily: 'Inter')), + onTap: () { + Navigator.pop(context); + _pickFiles(); + }, + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + Future _submitData() async { + if (_idMurid == null || _userIdAdmin == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal: Data profil tidak lengkap! Coba muat ulang aplikasi.'))); + return; + } + + if (_startDate == null || _endDate == null || _keteranganController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Harap lengkapi isi tanggal dan keterangan izin'))); + return; + } + + if (_selectedFiles.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Harap unggah minimal 1 foto/dokumen surat izin'))); + return; + } + + setState(() { + _isSubmitting = true; + }); + + try { + final dbIzin = DbIzin(); + final currentUserId = Supabase.instance.client.auth.currentUser?.id ?? _userIdAdmin!; + final List lampiranUrls = []; + + for (var file in _selectedFiles) { + if (file.bytes == null) { + throw Exception("Gagal membaca data file: ${file.name}"); + } + final url = await dbIzin.uploadIzinFile(currentUserId, _studentNis!, file.bytes!, file.name); + lampiranUrls.add(url); + } + + final lampiranString = lampiranUrls.join(' '); + + // Insert to Table leave_request + final data = { + 'tanggal_mulai': DateFormat('yyyy-MM-dd').format(_startDate!), + 'tanggal_selesai': DateFormat('yyyy-MM-dd').format(_endDate!), + 'keterangan': _keteranganController.text, + 'verif': null, + 'id_murid': _idMurid, + 'nama_murid': _studentName, + 'id_guru': _idGuru, + 'user_id': currentUserId, + 'id_kelas': _idClass, + 'ket_opsi': _ketOpsi, + }; + + final inserted = await dbIzin.submitIzin(data); + final idIzin = inserted['id_tabel']; + + if (_idGuru == null || _idGuru!.isEmpty) { + throw Exception("Wali kelas tidak ditemukan. (Debug: id_murid=$_idMurid, id_class=$_idClass, id_guru=$_idGuru)"); + } + + final startDateStr = DateFormat('dd MMM yyyy').format(_startDate!); + final endDateStr = DateFormat('dd MMM yyyy').format(_endDate!); + final jenisLabel = _ketOpsi == 'sakit' ? 'Sakit' : 'Izin'; + final textPesanPrivate = "📋 *Pengajuan Izin*\nJenis: $jenisLabel\nNama: $_studentName\nTanggal: $startDateStr s/d $endDateStr\nKeterangan: ${_keteranganController.text}\nLampiran: $lampiranString\nID_IZIN: $idIzin"; + + await Supabase.instance.client.from('chat_private').insert({ + 'text': textPesanPrivate, + 'pengirim_murid': _idMurid, + 'penerima_guru': _idGuru, + 'user_id': currentUserId, + 'id_izin': idIzin, + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Izin berhasil diajukan!'))); + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => MainScreenOrtu()), + (route) => false, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Terjadi kesalahan: $e'))); + } + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Pengajuan Izin"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: _isLoadingData + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('Nama'), + _buildReadOnlyField(_studentName), + const SizedBox(height: 20), + + _buildLabel('Tanggal Mulai'), + GestureDetector( + onTap: () => _pickDate(context, true), + child: _buildDateBox(_startDate), + ), + const SizedBox(height: 20), + + _buildLabel('Tanggal Selesai'), + GestureDetector( + onTap: () => _pickDate(context, false), + child: _buildDateBox(_endDate), + ), + const SizedBox(height: 20), + + _buildLabel('Pilihan Izin (Izin / Sakit)'), + Row( + children: [ + Expanded( + child: ChoiceChip( + label: const Center(child: Text('Izin')), + selected: _ketOpsi == 'izin', + selectedColor: const Color(0xFF2563EB), + checkmarkColor: Colors.white, + labelStyle: TextStyle( + fontFamily: 'Inter', + color: _ketOpsi == 'izin' ? Colors.white : Colors.black87, + fontWeight: FontWeight.bold, + ), + onSelected: (selected) { + if (selected) setState(() => _ketOpsi = 'izin'); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ChoiceChip( + label: const Center(child: Text('Sakit')), + selected: _ketOpsi == 'sakit', + selectedColor: const Color(0xFF2563EB), + checkmarkColor: Colors.white, + labelStyle: TextStyle( + fontFamily: 'Inter', + color: _ketOpsi == 'sakit' ? Colors.white : Colors.black87, + fontWeight: FontWeight.bold, + ), + onSelected: (selected) { + if (selected) setState(() => _ketOpsi = 'sakit'); + }, + ), + ), + ], + ), + const SizedBox(height: 20), + + _buildLabel('Keterangan Izin'), + TextFormField( + controller: _keteranganController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Jelaskan alasan izin...', + hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 14), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF2563EB)), + ), + ), + ), + const SizedBox(height: 24), + + _buildLabel('Foto / Dokumen Surat Izin'), + GestureDetector( + onTap: _showAttachmentOptions, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFE2E8F0), + width: 1, + ), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Color(0xFFEFF6FF), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.file_upload_outlined, + color: Color(0xFF2563EB), + size: 32, + ), + ), + const SizedBox(height: 12), + const Text( + 'Unggah Foto / Dokumen', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + const Text( + 'Mendukung banyak file & gambar dari kamera', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF94A3B8), + ), + ), + ], + ), + ), + ), + + _buildFileList(), + const SizedBox(height: 40), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _isSubmitting ? null : _submitData, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Text( + 'Ajukan Izin', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ); + } + + Widget _buildFileList() { + if (_selectedFiles.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + _buildLabel('File Terpilih (${_selectedFiles.length})'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(_selectedFiles.length, (index) { + final file = _selectedFiles[index]; + final extension = file.name.split('.').last.toLowerCase(); + final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); + final extLabel = extension.length > 4 + ? extension.substring(0, 4).toUpperCase() + : extension.toUpperCase(); + final cleanName = file.name.replaceFirst(RegExp(r'^\d{13}_'), ''); + + return Stack( + clipBehavior: Clip.none, + children: [ + if (isImage && file.bytes != null) + GestureDetector( + onTap: () { + // Pratinjau gambar penuh menggunakan bytes lokal + showDialog( + context: context, + builder: (_) => Dialog( + backgroundColor: Colors.black, + insetPadding: EdgeInsets.zero, + child: Stack( + children: [ + InteractiveViewer( + child: Image.memory( + file.bytes!, + fit: BoxFit.contain, + width: double.infinity, + height: double.infinity, + ), + ), + Positioned( + top: 16, + right: 16, + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, color: Colors.white, size: 20), + ), + ), + ), + ], + ), + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.memory( + file.bytes!, + width: 110, + height: 110, + fit: BoxFit.cover, + ), + ), + ) + else + Container( + constraints: const BoxConstraints(maxWidth: 200), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extLabel, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF2563EB), + fontFamily: 'Inter', + ), + ), + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cleanName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF1E293B), + fontWeight: FontWeight.w500, + fontFamily: 'Inter', + ), + ), + const SizedBox(height: 2), + Text( + '${extLabel} • ${(file.size / 1024).toStringAsFixed(1)} KB', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + fontFamily: 'Inter', + ), + ), + ], + ), + ), + ], + ), + ), + // Tombol hapus di pojok kanan atas + Positioned( + top: -6, + right: -6, + child: GestureDetector( + onTap: () { + setState(() { + _selectedFiles.removeAt(index); + }); + }, + child: Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + color: Colors.redAccent, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, color: Colors.white, size: 14), + ), + ), + ), + ], + ); + }), + ), + ], + ); + } + + Widget _buildLabel(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF334155), + ), + ), + ); + } + + Widget _buildReadOnlyField(String text) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFF475569), + ), + ), + ); + } + + Widget _buildDateBox(DateTime? date) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.calendar_today_outlined, color: Color(0xFF94A3B8), size: 20), + const SizedBox(width: 12), + Text( + date != null ? DateFormat('MM/dd/yyyy').format(date) : 'mm/dd/yyyy', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: date != null ? const Color(0xFF1E293B) : const Color(0xFF94A3B8), + ), + ), + ], + ), + const Icon(Icons.calendar_month, color: Color(0xFF1E293B), size: 20), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/p_pengumuman.dart/preview_p.dart b/lib/dashboard/d_c_murid/function/p_pengumuman.dart/preview_p.dart new file mode 100644 index 0000000..a657a39 --- /dev/null +++ b/lib/dashboard/d_c_murid/function/p_pengumuman.dart/preview_p.dart @@ -0,0 +1,417 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_pengumuman.dart'; +import 'package:apk/dashboard/d_c_murid/function/f_Pesan/doc_image.dart'; + +class PreviewPengumuman extends StatefulWidget { + const PreviewPengumuman({super.key}); + + @override + State createState() => _PreviewPengumumanState(); +} + +class _PreviewPengumumanState extends State { + final supabase = Supabase.instance.client; + bool _isLoading = true; + String? _errorMessage; + List> _pengumumanList = []; + + @override + void initState() { + super.initState(); + _fetchPengumuman(); + } + + Future _fetchPengumuman() async { + try { + // Hapus otomatis pengumuman yang sudah berakhir + await DbPengumuman.deleteExpiredPengumuman(); + + // Mengambil semua pengumuman yang diurutkan dari yang terbaru + final response = await supabase + .from('pengumuman') + .select() + .order('created_at', ascending: false); + + setState(() { + _pengumumanList = List>.from(response); + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Gagal memuat pengumuman: $e'; + _isLoading = false; + }); + } + } + + String _formatDate(String isoString) { + try { + final date = DateTime.parse(isoString); + return DateFormat('dd MMM yyyy').format(date); + } catch (_) { + return isoString; + } + } + + // Cek jika pengumuman masih aktif berdasarkan tanggal selesai + bool _isActive(String tanggalSelesai) { + try { + final date = DateTime.parse(tanggalSelesai); + final now = DateTime.now(); + // Bandingkan tanpa peduli jam (hanya tanggal) + final endDate = DateTime(date.year, date.month, date.day, 23, 59, 59); + return now.isBefore(endDate) || now.isAtSameMomentAs(endDate); + } catch (_) { + return true; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Pengumuman Sekolah & Kelas"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontFamily: 'Inter')), + ), + ) + : _pengumumanList.isEmpty + ? _buildEmptyState() + : RefreshIndicator( + onRefresh: _fetchPengumuman, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _pengumumanList.length, + itemBuilder: (context, index) { + final p = _pengumumanList[index]; + final bool active = _isActive(p['tanggal_selesai']); + + // Memisahkan keterangan dan URL lampiran jika ada + String textKeterangan = p['keterangan'] ?? ''; + List lampiranUrls = []; + + if (textKeterangan.contains('Lampiran: ')) { + final parts = textKeterangan.split('Lampiran: '); + textKeterangan = parts[0].trim(); + final urlsString = parts.length > 1 ? parts[1].trim() : ''; + lampiranUrls = urlsString.split(RegExp(r'\s+')).where((s) => s.startsWith('http')).toList(); + } + + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 2, + shadowColor: Colors.black.withOpacity(0.1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: active ? const Color(0xFF2563EB).withOpacity(0.3) : Colors.grey.withOpacity(0.3), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + p['title'] ?? 'Tanpa Judul', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: active ? const Color(0xFF1E293B) : Colors.grey[600], + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: active ? const Color(0xFFEFF6FF) : Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: Text( + active ? 'Aktif' : 'Berakhir', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.bold, + color: active ? const Color(0xFF2563EB) : Colors.grey[600], + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon(Icons.date_range, size: 14, color: Color(0xFF64748B)), + const SizedBox(width: 6), + Text( + '${_formatDate(p['tanggal_mulai'])} - ${_formatDate(p['tanggal_selesai'])}', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF64748B), + ), + ), + ], + ), + if (textKeterangan.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + textKeterangan, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFF334155), + ), + ), + ), + ], + if (lampiranUrls.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: lampiranUrls.map((lampiranUrl) { + return Builder( + builder: (context) { + final uri = Uri.tryParse(lampiranUrl); + String extension = 'FILE'; + String fileName = 'Lampiran'; + if (uri != null && uri.pathSegments.isNotEmpty) { + final fullName = Uri.decodeComponent(uri.pathSegments.last); + fileName = fullName.replaceFirst(RegExp(r'^\d{13}_'), ''); + final extIndex = fullName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fullName.length - 1) { + extension = fullName.substring(extIndex + 1).toLowerCase(); + } + } + bool isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); + + if (isImage) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DocImagePreviewPage(imageUrl: lampiranUrl), + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + lampiranUrl, + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ); + } else { + return GestureDetector( + onTap: () => _launchURL(lampiranUrl), + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.black12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4).toUpperCase() : extension.toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '${extension.toUpperCase()} • Dokumen', + style: const TextStyle( + fontSize: 12, + color: Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + }, + ); + }).toList(), + ), + ] else ...[ + const SizedBox(height: 16), + FutureBuilder( + future: _getDocUrl(p['id_tabel']), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + + final url = snapshot.data; + if (url != null && url.isNotEmpty) { + return SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: const Icon(Icons.attach_file, size: 18), + label: const Text('Buka Lampiran Dokumen', style: TextStyle(fontFamily: 'Inter')), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF2563EB), + side: const BorderSide(color: Color(0xFF2563EB)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () => _launchURL(url), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ], + ), + ), + ); + }, + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.campaign_outlined, size: 80, color: Colors.grey[300]), + const SizedBox(height: 16), + Text( + 'Tidak Ada Pengumuman', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Belum ada pengumuman yang dibagikan.', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + + Future _getDocUrl(String id) async { + try { + // Format file dari db_pengumuman.dart adalah doc_$id + // Namun kita perlu cek apakah file itu ada. Supabase API tidak punya "exists" yang simpel, + // tapi kita bisa coba membuat publicUrl. + final String url = Supabase.instance.client.storage + .from('pengumuman') + .getPublicUrl('doc_$id'); + + // Supabase selalu mengembalikan URL publik meskipun file tidak ada. + // Untuk memvalidasi, bisa gunakan head/list, namun demi performa kita kembalikan saja URL-nya. + // Jika pengguna menekan, mereka akan diarahkan ke browser dan melihat NotFound jika kosong. + return url; + } catch (e) { + return null; + } + } + + Future _launchURL(String urlString) async { + final Uri url = Uri.parse(urlString); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Tidak dapat membuka tautan: $url')), + ); + } + } + } +} diff --git a/lib/dashboard/d_c_murid/function/setting/edit_data.dart b/lib/dashboard/d_c_murid/function/setting/edit_data.dart new file mode 100644 index 0000000..612ac5a --- /dev/null +++ b/lib/dashboard/d_c_murid/function/setting/edit_data.dart @@ -0,0 +1,447 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_murid.dart'; + +class EditDataOrtu extends StatefulWidget { + final Map data; + + const EditDataOrtu({ + super.key, + required this.data, + }); + + @override + State createState() => _EditDataOrtuState(); +} + +class _EditDataOrtuState extends State { + final service = DbMurid(); + + bool isSaving = false; + + late TextEditingController nisC; + late TextEditingController namaC; + late TextEditingController genderC; + late TextEditingController tglC; + late TextEditingController alamatC; + late TextEditingController ortuC; + late TextEditingController teleC; + + List> kelasList = []; + String? selectedKelas; + + @override + void initState() { + super.initState(); + + nisC = TextEditingController(text: widget.data['nis']?.toString() ?? ""); + namaC = TextEditingController(text: widget.data['nama'] ?? ""); + genderC = TextEditingController(text: widget.data['gender'] ?? ""); + tglC = TextEditingController( + text: widget.data['tanggal_lahir']?.toString() ?? "", + ); + alamatC = TextEditingController(text: widget.data['alamat'] ?? ""); + ortuC = TextEditingController(text: widget.data['orang_tua'] ?? ""); + teleC = TextEditingController( + text: widget.data['no_tele']?.toString() ?? "", + ); + + selectedKelas = widget.data['id_class']; + + loadKelas(); + } + + Future loadKelas() async { + final data = await service.getKelas(); + if (mounted) { + setState(() => kelasList = data); + } + } + + Future updateData() async { + setState(() => isSaving = true); + + try { + await service.updateMurid( + idTabel: widget.data['id_tabel'], + nis: nisC.text, + nama: namaC.text, + idClass: selectedKelas, + gender: genderC.text, + tanggalLahir: tglC.text.isEmpty ? null : DateTime.tryParse(tglC.text), + alamat: alamatC.text, + orangTua: ortuC.text, + noTele: num.tryParse(teleC.text), + ); + + final selectedKelasData = kelasList.where( + (kelas) => kelas['id_tabel'] == selectedKelas, + ); + + if (mounted) { + final Map updatedData = Map.from(widget.data); + updatedData['nis'] = nisC.text; + updatedData['nama'] = namaC.text; + updatedData['id_class'] = selectedKelas; + updatedData['gender'] = genderC.text; + updatedData['tanggal_lahir'] = tglC.text; + updatedData['alamat'] = alamatC.text; + updatedData['orang_tua'] = ortuC.text; + updatedData['no_tele'] = num.tryParse(teleC.text); + updatedData['class_name'] = selectedKelasData.isEmpty + ? null + : { + 'id_tabel': selectedKelasData.first['id_tabel'], + 'name_class': selectedKelasData.first['name_class'], + }; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Data murid berhasil diperbarui')), + ); + Navigator.pop(context, updatedData); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal menyimpan: $e')), + ); + } + } finally { + if (mounted) { + setState(() => isSaving = false); + } + } + } + + Future _pickDate() async { + final initialDate = DateTime.tryParse(tglC.text) ?? DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (picked != null) { + setState(() { + tglC.text = "${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}"; + }); + } + } + + Widget field(String label, TextEditingController c, {String hint = "", VoidCallback? onTap, bool readOnly = false}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 12, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + TextField( + controller: c, + onTap: onTap, + readOnly: readOnly, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget kelasField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Kelas", + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 12, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: selectedKelas, + decoration: InputDecoration( + hintText: "Pilih Kelas", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + items: kelasList.map((e) { + return DropdownMenuItem( + value: e['id_tabel'], + child: Text(e['name_class'], style: const TextStyle(fontFamily: 'Inter')), + ); + }).toList(), + onChanged: (v) => setState(() => selectedKelas = v), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget genderField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Gender", + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 12, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + DropdownButtonFormField( + isExpanded: true, + initialValue: genderC.text.isEmpty ? null : genderC.text, + decoration: InputDecoration( + hintText: "Pilih Gender", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + items: const [ + DropdownMenuItem(value: "L", child: Text("L", style: TextStyle(fontFamily: 'Inter'))), + DropdownMenuItem(value: "P", child: Text("P", style: TextStyle(fontFamily: 'Inter'))), + ], + onChanged: (v) => setState(() => genderC.text = v ?? ""), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + }) { + final bool isWhite = color == Colors.white; + return Expanded( + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: isWhite ? const Color(0xFF64748B) : Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: isWhite ? const BorderSide(color: Color(0xFF94A3B8)) : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: isWhite ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + void dispose() { + nisC.dispose(); + namaC.dispose(); + genderC.dispose(); + tglC.dispose(); + alamatC.dispose(); + ortuC.dispose(); + teleC.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Edit Data Murid"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: Container( + color: Colors.white, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Expanded( + child: ListView( + children: [ + Row( + children: const [ + Text("📋", style: TextStyle(fontSize: 12)), + SizedBox(width: 8), + Text( + "Data Utama", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("NIS *", nisC, hint: "Contoh: 332817"), + field("Nama *", namaC, hint: "Contoh: Budi Santoso"), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: genderField()), + const SizedBox(width: 16), + Expanded( + child: field( + "Tanggal Lahir", + tglC, + hint: "YYYY-MM-DD", + readOnly: true, + onTap: _pickDate, + ), + ), + ], + ), + kelasField(), + ], + ), + ), + + const SizedBox(height: 24), + + Row( + children: const [ + Text("📚", style: TextStyle(fontSize: 12)), + SizedBox(width: 8), + Text( + "Kontak & Orang Tua", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("Alamat", alamatC, hint: "Masukkan alamat"), + field("Orang Tua", ortuC, hint: "Nama orang tua"), + field("No Telepon", teleC, hint: "Nomor telepon"), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + _actionButton( + label: "Simpan", + color: const Color(0xFF2563EB), + onPressed: isSaving ? null : updateData, + loading: isSaving, + ), + const SizedBox(width: 10), + _actionButton( + label: "Batal", + color: Colors.white, + onPressed: isSaving ? null : () => Navigator.pop(context), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/function/setting/setting_data.dart b/lib/dashboard/d_c_murid/function/setting/setting_data.dart new file mode 100644 index 0000000..652eede --- /dev/null +++ b/lib/dashboard/d_c_murid/function/setting/setting_data.dart @@ -0,0 +1,489 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_c_murid/database/db_murid.dart'; +import 'package:apk/dashboard/d_c_murid/widget/custom_button.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_c_murid/function/setting/edit_data.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart'; + +class SettingDataOrtu extends StatefulWidget { + const SettingDataOrtu({super.key}); + + @override + State createState() => _SettingDataOrtuState(); +} + +class _SettingDataOrtuState extends State { + final service = DbMurid(); + + bool isLoading = true; + Map? data; + List> kelasList = []; + + @override + void initState() { + super.initState(); + _fetchStudentData(); + loadKelas(); + } + + Future loadKelas() async { + final kData = await service.getKelas(); + if (mounted) { + setState(() => kelasList = kData); + } + } + + Future _fetchStudentData() async { + setState(() { + isLoading = true; + }); + try { + final prefs = await SharedPreferences.getInstance(); + final nis = prefs.getString('murid_nis'); + final userId = prefs.getString('user_id_admin'); + + if (nis != null && userId != null) { + final response = await Supabase.instance.client + .from('murid') + .select() + .eq('user_id', userId) + .eq('nis', nis) + .single(); + + if (mounted) { + setState(() { + data = response; + isLoading = false; + }); + + String namaVal = response['nama'] ?? ""; + if (namaVal.isEmpty) { + for (var key in response.keys) { + if (key.toLowerCase() == 'nama' || key.toLowerCase() == 'name') { + namaVal = response[key].toString(); + break; + } + } + } + if (namaVal.isNotEmpty) { + prefs.setString('murid_nama', namaVal); + } + } + } else { + if (mounted) { + setState(() { + isLoading = false; + }); + } + } + } catch (e) { + if (mounted) { + setState(() { + isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error memuat data: $e')), + ); + } + } + } + + Future logout() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + await Supabase.instance.client.auth.signOut(); + if (mounted) { + Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal logout: $e')), + ); + } + } + } + + Widget field(String label, String value) { + return _infoCard( + icon: _iconForLabel(label), + title: label, + value: _displayValue(label, value), + isEmpty: value.isEmpty, + ); + } + + IconData _iconForLabel(String label) { + switch (label) { + case "NIS": + return Icons.badge_outlined; + case "Nama": + return Icons.person_outline; + case "Gender": + return Icons.person_pin_outlined; + case "Tanggal Lahir": + return Icons.calendar_month_outlined; + case "Alamat": + return Icons.location_on_outlined; + case "Orang Tua": + return Icons.groups_2_outlined; + case "No Telepon": + return Icons.phone_outlined; + default: + return Icons.info_outline; + } + } + + String get _nama { + String namaVal = data?['nama'] ?? ""; + if (namaVal.isEmpty && data != null) { + for (var key in data!.keys) { + if (key.toLowerCase() == 'nama' || key.toLowerCase() == 'name') { + namaVal = data![key].toString(); + break; + } + } + } + return namaVal.isEmpty ? "Belum diisi" : namaVal; + } + + String get _nis => data?['nis']?.toString() ?? "Belum diisi"; + + String get _kelas { + final selectedKelas = data?['id_class']; + if (selectedKelas != null && kelasList.isNotEmpty) { + final kelas = kelasList.firstWhere( + (k) => k['id_tabel'] == selectedKelas, + orElse: () => {}, + ); + return kelas['name_class']?.toString() ?? "Belum ada kelas"; + } + return "Belum ada kelas"; + } + + String _displayValue(String label, String value) { + if (value.isEmpty) return "Belum diisi"; + if (label != "Tanggal Lahir") return value; + + final date = DateTime.tryParse(value); + if (date == null) return value; + + final day = date.day.toString().padLeft(2, '0'); + final month = date.month.toString().padLeft(2, '0'); + return "$day-$month-${date.year}"; + } + + Widget _infoCard({ + required IconData icon, + required String title, + required String value, + bool isEmpty = false, + }) { + return CustomCard( + title: title, + subtitle: value, + icon: icon, + iconPosition: IconPosition.left, + iconColor: const Color(0xFF2563EB), + iconSize: 24, + iconContainerSize: 46, + iconDecoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(9), + ), + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + borderRadius: 0, + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + boxShadow: const [], + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF8FA0BA), + ), + subtitleStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w700, + fontStyle: isEmpty ? FontStyle.italic : FontStyle.normal, + color: isEmpty ? const Color(0xFFC8D2E0) : const Color(0xFF172033), + ), + showIcon: true, + ); + } + + Widget _sectionCard(List children) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8EEF6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) + const Divider( + height: 1, + thickness: 1, + color: Color(0xFFF0F4FA), + indent: 82, + endIndent: 20, + ), + ], + ], + ), + ), + ); + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 12), + child: Text( + title, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w800, + color: Color(0xFF64748B), + ), + ), + ); + } + + Widget _profileHeader() { + return SizedBox( + height: 128, + child: Stack( + alignment: Alignment.center, + children: [ + CustomCard( + title: "", + showIcon: false, + height: 126, + backgroundColor: Colors.white, + borderColor: const Color(0xFFE8EEF6), + borderWidth: 1, + borderRadius: 18, + margin: EdgeInsets.zero, + padding: EdgeInsets.zero, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.13), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _nama, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w800, + color: Color(0xFF172033), + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.badge_outlined, + size: 18, + color: Color(0xFF8FA0BA), + ), + const SizedBox(width: 6), + Text( + "NIS: $_nis", + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ], + ), + const SizedBox(height: 18), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.school_outlined, + size: 18, + color: Color(0xFF2563EB), + ), + const SizedBox(width: 6), + Text( + _kelas, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w700, + color: Color(0xFF2563EB), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + }) { + final bool isWhite = color == Colors.white; + return Expanded( + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: isWhite ? const Color(0xFF64748B) : Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: isWhite ? const BorderSide(color: Color(0xFF94A3B8)) : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: isWhite ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (isLoading) { + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + title: const Text("Pengaturan Profil", style: TextStyle(fontFamily: 'Inter', fontSize: 14, fontWeight: FontWeight.w600)), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1E293B), + elevation: 0, + ), + body: const Center(child: CircularProgressIndicator()), + ); + } + + if (data == null) { + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + title: const Text("Pengaturan Profil", style: TextStyle(fontFamily: 'Inter', fontSize: 14, fontWeight: FontWeight.w600)), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1E293B), + elevation: 0, + ), + body: const Center(child: Text("Data profil tidak ditemukan")), + ); + } + + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + title: const Text("Pengaturan Profil", style: TextStyle(fontFamily: 'Inter', fontSize: 14, fontWeight: FontWeight.w600)), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1E293B), + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), + children: [ + _profileHeader(), + const SizedBox(height: 20), + _sectionTitle("Identitas"), + _sectionCard([ + field("Gender", data?['gender'] ?? ""), + field("Tanggal Lahir", data?['tanggal_lahir']?.toString() ?? ""), + field("Alamat", data?['alamat'] ?? ""), + ]), + const SizedBox(height: 18), + _sectionTitle("Kontak Orang Tua"), + _sectionCard([ + field("Orang Tua", data?['orang_tua'] ?? ""), + field("No Telepon", data?['no_tele']?.toString() ?? ""), + ]), + const SizedBox(height: 24), + Row( + children: [ + _actionButton( + label: "Edit Profil", + color: const Color(0xFF2563EB), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => EditDataOrtu(data: data!)), + ).then((updated) { + if (updated != null && updated is Map) { + setState(() { + data = updated; + }); + } + }); + }, + ), + const SizedBox(width: 10), + _actionButton( + label: "Logout", + color: Colors.red, + onPressed: logout, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/widget/custom_button.dart b/lib/dashboard/d_c_murid/widget/custom_button.dart new file mode 100644 index 0000000..ffa4c0e --- /dev/null +++ b/lib/dashboard/d_c_murid/widget/custom_button.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; + +enum IconPosition { + left, + right, +} + +class CustomCard extends StatelessWidget { + final String title; + final String? subtitle; + final Widget? customSubtitle; + + // SIZE + final double? width; + final double? height; + + // ACTION + final VoidCallback? onTap; + + // CARD ALIGNMENT + final AlignmentGeometry alignment; + + // STYLE + final Color backgroundColor; + final Color borderColor; + final double borderWidth; + final double borderRadius; + + // TEXT STYLE + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; + + // TEXT DEFAULT + final TextAlign titleAlign; + final TextAlign subtitleAlign; + final int? titleMaxLines; + final int? subtitleMaxLines; + final TextOverflow? titleOverflow; + final TextOverflow? subtitleOverflow; + + // ICON + final IconData? icon; + final Color iconColor; + final double iconSize; + final bool showIcon; + + // SPACING + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry margin; + + // SHADOW + final List? boxShadow; + + final Widget? customIcon; + + final IconPosition iconPosition; + final BoxDecoration? iconDecoration; + final Gradient? iconGradient; + final EdgeInsetsGeometry iconPadding; + final double iconContainerSize; + final Gradient? gradient; + + const CustomCard({ + super.key, + required this.title, + this.subtitle, + this.customSubtitle, + + this.width, + this.height, + this.onTap, + + this.alignment = Alignment.centerLeft, + + this.backgroundColor = const Color(0xFFF1F5F9), + this.borderColor = const Color(0xFFBFDBFE), + this.borderWidth = 1, + this.borderRadius = 16, + + this.titleStyle, + this.subtitleStyle, + + //TEXT DEFAULT + this.titleAlign = TextAlign.left, + this.subtitleAlign = TextAlign.left, + this.titleMaxLines, + this.subtitleMaxLines, + this.titleOverflow, + this.subtitleOverflow, + + this.icon = Icons.chevron_right, + this.iconColor = const Color(0xFF94A3B8), + this.iconSize = 24, + this.showIcon = true, + + this.padding = const EdgeInsets.all(16), + this.margin = const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + + this.boxShadow, + + this.customIcon, + + this.iconPosition = IconPosition.right, + this.iconDecoration, + this.iconGradient, + this.iconPadding = const EdgeInsets.all(8), + this.iconContainerSize = 40, + this.gradient, + }); + + Widget _buildIcon() { + if (!showIcon) return const SizedBox(); + + Widget iconWidget = customIcon ?? + (icon != null + ? Icon( + icon, + color: iconColor, + size: iconSize, + ) + : const SizedBox()); + + return Container( + width: iconContainerSize, + height: iconContainerSize, + padding: iconPadding, + decoration: iconDecoration ?? + BoxDecoration( + gradient: iconGradient, + borderRadius: BorderRadius.circular(12), + ), + child: Center(child: iconWidget), + ); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: alignment, + child: InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onTap, + child: Container( + width: width ?? double.infinity, + height: height, + margin: margin, + padding: padding, + decoration: BoxDecoration( + color: gradient == null ? backgroundColor : null, + gradient: gradient, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: borderColor, + width: borderWidth, + ), + boxShadow: boxShadow ?? + [ + BoxShadow( + // ignore: deprecated_member_use + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // ICON + if (iconPosition == IconPosition.left) ...[ + _buildIcon(), + const SizedBox(width: 8), + ], + + // TEXT + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + + // 🔥 ini ikut textAlign + crossAxisAlignment: titleAlign == TextAlign.right + ? CrossAxisAlignment.end + : titleAlign == TextAlign.center + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + + children: [ + Text( + title, + textAlign: titleAlign, + maxLines: titleMaxLines, + overflow: titleOverflow, + style: titleStyle ?? + const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + ), + if (customSubtitle != null) ...[ + const SizedBox(height: 4), + customSubtitle!, + ] else if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + textAlign: subtitleAlign, + maxLines: subtitleMaxLines, + overflow: subtitleOverflow, + style: subtitleStyle ?? + const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFF64748B), + ), + ), + ], + ], + ), + ), + if (iconPosition == IconPosition.right) ...[ + const SizedBox(width: 8), + _buildIcon(), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/dashboard/d_c_murid/widget/custom_textfiled.dart b/lib/dashboard/d_c_murid/widget/custom_textfiled.dart new file mode 100644 index 0000000..dbf0bd1 --- /dev/null +++ b/lib/dashboard/d_c_murid/widget/custom_textfiled.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +class CustomTextField extends StatefulWidget { + final TextEditingController controller; + final String label; + final String? hint; + final IconData? prefixIcon; + final Widget? suffixIcon; + + final bool isPassword; + final bool readOnly; + final bool enabled; + final TextInputType keyboardType; + final int maxLines; + + final String? Function(String?)? validator; + final void Function(String)? onChanged; + final VoidCallback? onTap; + + final Color borderColor; + final Color focusColor; + + const CustomTextField({ + super.key, + required this.controller, + required this.label, + this.hint, + this.prefixIcon, + this.suffixIcon, + this.isPassword = false, + this.readOnly = false, + this.enabled = true, + this.keyboardType = TextInputType.text, + this.maxLines = 1, + this.validator, + this.onChanged, + this.onTap, + this.borderColor = Colors.grey, + this.focusColor = Colors.blue, + }); + + @override + State createState() => _CustomTextFieldState(); +} + +class _CustomTextFieldState extends State { + bool obscureText = true; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: widget.controller, + obscureText: widget.isPassword ? obscureText : false, + readOnly: widget.readOnly, + enabled: widget.enabled, + keyboardType: widget.keyboardType, + maxLines: widget.isPassword ? 1 : widget.maxLines, + validator: widget.validator, + onChanged: widget.onChanged, + onTap: widget.onTap, + + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hint, + + prefixIcon: widget.prefixIcon != null + ? Icon(widget.prefixIcon) + : null, + + suffixIcon: widget.isPassword + ? IconButton( + icon: Icon( + obscureText + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + obscureText = !obscureText; + }); + }, + ) + : widget.suffixIcon, + + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: widget.borderColor), + ), + + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: widget.borderColor), + ), + + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: widget.focusColor, width: 2), + ), + ), + ); + } +} diff --git a/lib/dashboard/d_guru/bottom_bar/nav_bar.dart b/lib/dashboard/d_guru/bottom_bar/nav_bar.dart new file mode 100644 index 0000000..010488e --- /dev/null +++ b/lib/dashboard/d_guru/bottom_bar/nav_bar.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/grup_sekolah_n.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/grup_kelas_n.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/chat_private_n.dart'; + +class BottomNavG extends StatelessWidget { + final int selectedIndex; + final Function(int) onTap; + + const BottomNavG({ + super.key, + required this.selectedIndex, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 50, // Mengatur tinggi bar + child: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + selectedFontSize: 0, + unselectedFontSize: 0, + type: BottomNavigationBarType.fixed, + currentIndex: selectedIndex, + onTap: onTap, + + items: [ + BottomNavigationBarItem( + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selectedIndex == 0 + ? Colors.blue + : Color(0xFFF1F5F9), + ), + child: Image.asset( + selectedIndex == 0 + ? 'lib/assets/icons/w_building.png' + : 'lib/assets/icons/a_building.png', + width: 24, + height: 24, + ), + ), + if (selectedIndex == 0) SizedBox(height: 2), + if (selectedIndex == 0) + Text( + 'Daftar Kelas', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.blue, + ), + ), + ], + ), + label: '', + ), + + BottomNavigationBarItem( + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selectedIndex == 1 + ? Colors.blue + : Color(0xFFF1F5F9), + ), + child: Image.asset( + selectedIndex == 1 + ? 'lib/assets/icons/w_note.png' + : 'lib/assets/icons/a_note.png', + width: 24, + height: 24, + ), + ), + if (selectedIndex == 1) SizedBox(height: 2), + if (selectedIndex == 1) + Text('Absensi', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.blue, + )), + ], + ), + label: '', + ), + + BottomNavigationBarItem( + icon: ValueListenableBuilder( + valueListenable: GrupSekolahNotification.hasUnread, + builder: (context, unreadSekolah, child) { + return ValueListenableBuilder( + valueListenable: GrupKelasNotification.hasUnread, + builder: (context, unreadKelas, child) { + return ValueListenableBuilder( + valueListenable: ChatPrivateNotification.hasUnread, + builder: (context, unreadPrivate, child) { + final hasAnyUnread = unreadSekolah || unreadKelas || unreadPrivate; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selectedIndex == 2 + ? Colors.blue + : const Color(0xFFF1F5F9), + ), + child: Image.asset( + selectedIndex == 2 + ? 'lib/assets/icons/w_bell.png' + : 'lib/assets/icons/a_bell.png', + width: 24, + height: 24, + ), + ), + if (hasAnyUnread) + Positioned( + top: -2, + right: -2, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + if (selectedIndex == 2) const SizedBox(height: 2), + if (selectedIndex == 2) + const Text('Pengumuman', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.blue, + )), + ], + ); + }, + ); + }, + ); + }, + ), + label: '', + ), + + BottomNavigationBarItem( + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: selectedIndex == 3 + ? Colors.blue + : Color(0xFFF1F5F9), + ), + child: Image.asset( + selectedIndex == 3 + ? 'lib/assets/icons/w_people.png' + : 'lib/assets/icons/a_people.png', + width: 24, + height: 24, + ), + ), + if (selectedIndex == 3) SizedBox(height: 2), + if (selectedIndex == 3) + Text('Daftar Guru', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.blue, + )), + ], + ), + label: '', + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/d_guru/bottom_bar/nav_screen_g.dart b/lib/dashboard/d_guru/bottom_bar/nav_screen_g.dart new file mode 100644 index 0000000..1e84e10 --- /dev/null +++ b/lib/dashboard/d_guru/bottom_bar/nav_screen_g.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_guru/bottom_bar/nav_bar.dart'; +import 'package:apk/dashboard/d_guru/dashboard/absensi.dart'; +import 'package:apk/dashboard/d_guru/dashboard/pengumuman.dart'; +import 'package:apk/dashboard/d_guru/dashboard/guru.dart'; +import 'package:apk/dashboard/d_guru/dashboard/murid.dart'; +import 'package:apk/closed_app/closed.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/grup_kelas_n.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/grup_sekolah_n.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/chat_private_n.dart'; +import 'package:apk/error_handler/connection.dart'; +import 'package:apk/session_timer/session_time.dart'; + +typedef NavScreenG = MainScreenGuru; + +class MainScreenGuru extends StatefulWidget { + const MainScreenGuru({super.key}); + + @override + State createState() => _MainScreenGuruState(); +} + +class _MainScreenGuruState extends State { + int selectedIndex = 0; + + @override + void initState() { + super.initState(); + GrupKelasNotification.init(); + GrupSekolahNotification.init(); + ChatPrivateNotification.init(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkInternetConnection(); + }); + } + + Future _checkInternetConnection() async { + bool hasConnection = await ConnectionHandler.checkConnection(); + if (!hasConnection && mounted) { + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () => _checkInternetConnection(), + ); + } + } + + final List pages = [ + const MuridPage(), + const Absensi(), + const Pengumuman(), + const GuruPage(), + ]; + + void onItemTapped(int index) { + setState(() { + selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return SessionTimeManager( + child: WillPopScope( + onWillPop: () => handleDoubleTapToExit(context), + child: Scaffold( + body: IndexedStack( + index: selectedIndex, + children: pages, + ), + bottomNavigationBar: BottomNavG( + selectedIndex: selectedIndex, + onTap: onItemTapped, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/d_guru/dashboard/absensi.dart b/lib/dashboard/d_guru/dashboard/absensi.dart new file mode 100644 index 0000000..1184fdc --- /dev/null +++ b/lib/dashboard/d_guru/dashboard/absensi.dart @@ -0,0 +1,406 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:apk/dashboard/d_guru/database/db_kelas.dart'; +import 'package:apk/dashboard/d_guru/database/db_namasekolah.dart'; +import 'package:apk/dashboard/d_guru/function/absen_excel/daftar_absen.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/d_guru/function/absensi_baru/form_daftar_absen.dart'; +import 'package:apk/dashboard/d_guru/function/absensi_baru/daftar_absen.dart' as absen_baru; +import 'package:apk/error_handler/connection.dart'; + +class Absensi extends StatefulWidget { + const Absensi({super.key}); + + @override + State createState() => _AbsensiState(); +} + +class _AbsensiState extends State { + final DbKelas _dbKelas = DbKelas(); + bool _isLoading = true; + String? _errorMessage; + List> _muridList = []; + String _namaKelas = '-'; + String _namaWaliKelas = '-'; + String _nikWaliKelas = '-'; + String _wilayah = ''; + String _namaSekolah = ''; + + // Generate list bulan + final List _months = []; + + @override + void initState() { + super.initState(); + _initData(); + } + + Future _initData() async { + setState(() => _isLoading = true); + await initializeDateFormatting('id_ID', null); + + bool hasConnection = await ConnectionHandler.checkConnection(); + if (!hasConnection) { + if (mounted) { + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () => _initData(), + ); + setState(() { + _errorMessage = 'Tidak ada koneksi internet'; + _isLoading = false; + }); + } + return; + } + + try { + final supabase = Supabase.instance.client; + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nik = prefs.getString('guru_nik'); + _nikWaliKelas = nik ?? '-'; + + if (userId == null || nik == null) { + setState(() { + _errorMessage = 'Sesi tidak valid'; + _isLoading = false; + }); + return; + } + + // 1. Dapatkan info guru yang login + final guruData = await supabase + .from('guru') + .select('id_tabel, id_class') + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + + if (guruData == null) { + setState(() { + _errorMessage = 'Data guru tidak ditemukan'; + _isLoading = false; + }); + return; + } + + final guruIdTabel = guruData['id_tabel']; + final guruIdClass = guruData['id_class']; + + // 2. Cari tanggal dibuatnya kelas (created_at paling awal dari kelas yang terhubung) + DateTime? earliestCreatedAt; + + // Cek kelas dari relasi id_guru di tabel class_name + final classDataList = await supabase + .from('class_name') + .select('id_tabel, name_class, created_at, id_class') + .eq('id_guru', guruIdTabel); + + for (var c in classDataList) { + if (c['created_at'] != null) { + final createdAt = DateTime.parse(c['created_at'].toString()); + if (earliestCreatedAt == null || createdAt.isBefore(earliestCreatedAt)) { + earliestCreatedAt = createdAt; + } + } + } + + // Jika guru ini bukan wali kelas, cek dari id_class-nya + if (guruIdClass != null && earliestCreatedAt == null) { + final classData = await supabase + .from('class_name') + .select('created_at') + .eq('id_tabel', guruIdClass) + .maybeSingle(); + if (classData != null && classData['created_at'] != null) { + earliestCreatedAt = DateTime.parse(classData['created_at'].toString()); + } + } + + // 3. Generate bulan dari earliestCreatedAt hingga sekarang + _months.clear(); + final now = DateTime.now(); + + if (earliestCreatedAt != null) { + int startYear = earliestCreatedAt.year; + int startMonth = earliestCreatedAt.month; + int currentYear = now.year; + int currentMonth = now.month; + + for (int y = currentYear; y >= startYear; y--) { + int mStart = (y == startYear) ? startMonth : 1; + int mEnd = (y == currentYear) ? currentMonth : 12; + for (int m = mEnd; m >= mStart; m--) { + _months.add(DateTime(y, m, 1)); + } + } + } else { + // Fallback jika tidak ditemukan kelas + _months.add(DateTime(now.year, now.month, 1)); + } + + // Ambil nama sekolah dan wilayah + final sekolahInfo = await NamaSekolahService.getSekolahInfo(); + _namaSekolah = sekolahInfo['sekolah'] ?? ''; + _wilayah = sekolahInfo['wilayah'] ?? ''; + + // 4. Ambil data murid + final murid = await _dbKelas.getMuridForGuru(); + + // 5. Ambil nama kelas dan nama wali kelas + String namaKelas = '-'; + String namaWaliKelas = '-'; + + // Ambil nama kelas dari class_name (dari guru yang login sebagai wali kelas) + if (classDataList.isNotEmpty) { + final kelasRow = classDataList.first; + if (kelasRow['name_class'] != null) { + namaKelas = kelasRow['name_class'].toString(); + } + } else if (guruIdClass != null) { + // Fallback: guru bukan wali kelas tapi punya id_class + final kelasData = await supabase + .from('class_name') + .select('name_class') + .eq('id_tabel', guruIdClass) + .maybeSingle(); + if (kelasData != null && kelasData['name_class'] != null) { + namaKelas = kelasData['name_class'].toString(); + } + } + + // Ambil nama wali kelas (nama guru yang login) + final guruNamaRow = await supabase + .from('guru') + .select('name') + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + if (guruNamaRow != null && guruNamaRow['name'] != null) { + namaWaliKelas = guruNamaRow['name'].toString(); + } + + setState(() { + _muridList = murid; + _namaKelas = namaKelas; + _namaWaliKelas = namaWaliKelas; + _isLoading = false; + _errorMessage = null; + }); + + } catch (e) { + setState(() { + _errorMessage = 'Gagal memuat data: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + titleSpacing: 20, + title: const Text('Rekap Absensi Kelas'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + onPressed: () { + _initData(); + }, + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontFamily: 'Inter')), + ), + ) + : Column( + children: [ + _buildAbsensiBaruMenu(), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text("Rekap Bulanan", style: TextStyle(fontFamily: 'Inter', fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1E293B))), + ), + ), + Expanded( + child: _muridList.isEmpty + ? _buildEmptyState() + : _buildMonthList(), + ), + ], + ), + ); + } + + Widget _buildAbsensiBaruMenu() { + return Column( + children: [ + const SizedBox(height: 16), + CustomCard( + title: "Buat Absensi", + subtitle: "Buat sesi absensi baru (Misal: Ujian/Pelajaran)", + icon: Icons.add_circle_outline, + iconColor: const Color(0xFF2563EB), + backgroundColor: Colors.white, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FormDaftarAbsenPage()), + ); + }, + ), + CustomCard( + title: "Lihat Daftar", + subtitle: "Daftar sesi absensi yang telah dibuat", + icon: Icons.list_alt, + iconColor: const Color(0xFF10B981), + backgroundColor: Colors.white, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const absen_baru.DaftarAbsenSesiPage()), + ); + }, + ), + ], + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.people_outline, size: 80, color: Colors.grey[300]), + const SizedBox(height: 16), + Text( + 'Tidak Ada Murid', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Anda belum memiliki murid di kelas Anda.', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + + Widget _buildMonthList() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _months.length, + itemBuilder: (context, index) { + final date = _months[index]; + final monthName = DateFormat('MMMM yyyy', 'id_ID').format(date); + // Format YYYY-MM untuk query ke database + final yearMonth = DateFormat('yyyy-MM').format(date); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shadowColor: Colors.black.withOpacity(0.1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DaftarAbsenExcelPage( + monthName: monthName, + yearMonth: yearMonth, + muridList: _muridList, + namaKelas: _namaKelas, + namaWaliKelas: _namaWaliKelas, + nipWaliKelas: _nikWaliKelas, + wilayah: _wilayah, + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.calendar_month, color: Color(0xFF2563EB)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Rekap $monthName', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + Text( + '${_muridList.length} Siswa', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: Color(0xFF64748B), + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF94A3B8)), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/dashboard/d_guru/dashboard/guru.dart b/lib/dashboard/d_guru/dashboard/guru.dart new file mode 100644 index 0000000..d586dc4 --- /dev/null +++ b/lib/dashboard/d_guru/dashboard/guru.dart @@ -0,0 +1,386 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_guru/database/db_guru.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; + +import 'package:apk/dashboard/d_guru/function/d_guru/daftar_guru.dart'; +import 'package:apk/dashboard/d_guru/function/setting_p/setting_p.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/error_handler/connection.dart'; + +class GuruPage extends StatefulWidget { + const GuruPage({super.key}); + + @override + State createState() => _GuruPageState(); +} + +class _GuruPageState extends State { + final DbGuru _dbGuru = DbGuru(); + List> guruList = []; + bool isLoading = true; + String? errorMessage; + + String guruName = 'Memuat...'; + String guruNik = '...'; + bool isWaliKelas = false; + String? className; + bool isLoadingGuru = true; + Map? _rawGuruData; + + @override + void initState() { + super.initState(); + _fetchGuruData(); + loadData(); + } + + Future _fetchGuruData() async { + bool hasConnection = await ConnectionHandler.checkConnection(); + if (!hasConnection) { + if (mounted) { + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () => _fetchGuruData(), + ); + setState(() { + guruName = 'Tidak Ada Koneksi'; + isLoadingGuru = false; + }); + } + return; + } + try { + final guruData = await _dbGuru.getLoggedInGuruInfo(); + if (mounted) { + setState(() { + _rawGuruData = guruData; + String? nameValue; + for (var key in guruData.keys) { + if (key.toLowerCase().contains('nama') || key.toLowerCase().contains('name')) { + nameValue = guruData[key].toString(); + break; + } + } + guruName = nameValue ?? 'Tanpa Nama'; + guruNik = guruData['nik']?.toString() ?? '-'; + isWaliKelas = guruData['is_wali_kelas'] ?? false; + className = guruData['class_name']; + isLoadingGuru = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + guruName = 'Error memuat profil'; + guruNik = '-'; + isLoadingGuru = false; + }); + } + } + } + + void _showSettingsBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 5, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(10), + ), + ), + ListTile( + leading: const Icon(Icons.person_outline, color: Color(0xFF1E293B)), + title: const Text('Setting Profil', style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600)), + onTap: () async { + Navigator.pop(context); // Close bottom sheet + if (_rawGuruData != null) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SettingProfilPage(data: _rawGuruData!), + ), + ); + _fetchGuruData(); // Refresh data after returning + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Data profil belum siap.')), + ); + } + }, + ), + ListTile( + leading: const Icon(Icons.logout, color: Colors.red), + title: const Text('Logout', style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, color: Colors.red)), + onTap: () async { + Navigator.pop(context); // Close bottom sheet + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + await Supabase.instance.client.auth.signOut(); + + if (context.mounted) { + Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal logout: $e')), + ); + } + } + }, + ), + ], + ), + ); + }, + ); + } + + Future loadData() async { + try { + final prefs = await SharedPreferences.getInstance(); + final currentNik = prefs.getString('guru_nik'); + + final data = await _dbGuru.getGuruForLoggedGuruClass(); + + setState(() { + guruList = data.where((g) => g['nik'].toString() != currentNik).toList(); + isLoading = false; + errorMessage = null; + }); + } catch (e) { + setState(() { + isLoading = false; + errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } + + String safe(dynamic value) { + if (value == null || value.toString().isEmpty) return "N/A"; + return value.toString(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + titleSpacing: 20, + title: const Text("Manajemen Guru"), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + onPressed: () { + setState(() { + isLoading = true; + isLoadingGuru = true; + errorMessage = null; + }); + _fetchGuruData(); + loadData(); + }, + ), + ), + ], + ), + body: Column( + children: [ + // GURU INFO CARD + Container( + color: const Color(0xFF2563EB), // Extend the blue background slightly + padding: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + + const SizedBox(width: 16), + + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + guruName, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + Text( + 'NIK/NIP: $guruNik', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: Color(0xFF64748B), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isWaliKelas ? const Color(0xFFD1FAE5) : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + isWaliKelas ? 'Wali Kelas ${className ?? ''}' : 'Bukan Wali Kelas', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.bold, + color: isWaliKelas ? const Color(0xFF059669) : const Color(0xFF64748B), + ), + ), + ), + ], + ), + ], + ), + ), + + // Settings Icon + IconButton( + icon: const Icon(Icons.settings_outlined, color: Color(0xFF64748B)), + onPressed: () => _showSettingsBottomSheet(context), + ), + ], + ), + ), + ), + ), + Expanded( + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + "Terjadi Kesalahan:\n$errorMessage", + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red, fontFamily: 'Inter'), + ), + ), + ) + : guruList.isEmpty + ? const Center( + child: Text( + "Belum ada data guru", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + color: Colors.grey, + ), + ), + ) + : Column( + children: [ + const SizedBox(height: 12), + Expanded( + child: ListView.builder( + itemCount: guruList.length, + itemBuilder: (context, index) { + final g = guruList[index]; + final nama = safe(g['name']); + final nik = safe(g['nik']); + final isWali = g['wali'] == true; + + final classNameObj = g['class_name']; + String kelas = 'Belum ada kelas'; + + if (classNameObj != null) { + if (classNameObj is List && classNameObj.isNotEmpty) { + kelas = classNameObj[0]['name_class']?.toString() ?? 'Belum ada kelas'; + } else if (classNameObj is Map) { + kelas = classNameObj['name_class']?.toString() ?? 'Belum ada kelas'; + } + } + + final bidang = safe(g['bidang']); + final detail = isWali + ? 'Wali Kelas: $kelas' + : 'Bidang: $bidang'; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 0, + ), + child: CustomCard( + title: nama, + subtitle: 'NIK: $nik\n$detail', + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DetailGuruPage(data: g), + ), + ); + loadData(); + }, + ), + ), + const SizedBox(height: 12), + ], + ); + }, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_guru/dashboard/murid.dart b/lib/dashboard/d_guru/dashboard/murid.dart new file mode 100644 index 0000000..2192f59 --- /dev/null +++ b/lib/dashboard/d_guru/dashboard/murid.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_guru/database/db_kelas.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/d_guru/database/db_guru.dart'; +import 'package:apk/dashboard/d_guru/database/db_namasekolah.dart'; +import 'package:apk/dashboard/d_guru/function/detail_murid/detail_murid.dart'; +import 'package:apk/error_handler/connection.dart'; + +class MuridPage extends StatefulWidget { + const MuridPage({super.key}); + + @override + State createState() => _MuridPageState(); +} + +class _MuridPageState extends State { + bool isLoading = true; + List> _allMurid = []; + bool isSearchActive = false; + String? searchQuery; + String namaSekolah = "Memuat..."; + String namaKelas = ""; + + @override + void initState() { + super.initState(); + fetchData(); + } + + Future fetchData() async { + bool hasConnection = await ConnectionHandler.checkConnection(); + if (!hasConnection) { + if (mounted) { + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () => fetchData(), + ); + setState(() => isLoading = false); + } + return; + } + try { + final data = await DbKelas().getMuridForGuru(); + + String className = "Tidak Terdaftar"; + try { + final guruData = await DbGuru().getLoggedInGuruInfo(); + if (guruData['class_name'] != null && guruData['class_name'].toString().isNotEmpty) { + className = "${guruData['class_name']}"; + } + } catch (e) { + // Abaikan jika gagal ambil info guru + } + + String fetchedSekolah = "Tidak Terdaftar"; + try { + final schoolName = await NamaSekolahService.getNamaSekolah(); + if (schoolName != null && schoolName.isNotEmpty) { + fetchedSekolah = schoolName; + } + } catch (e) { + // Abaikan jika gagal ambil nama sekolah + } + + if (mounted) { + setState(() { + _allMurid = data; + namaSekolah = fetchedSekolah; + namaKelas = className; + isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + int dataCount = _allMurid.length; + + // Apply search filter + final keyword = (searchQuery ?? '').toLowerCase(); + final muridList = _allMurid.where((murid) { + final nama = murid['nama']?.toString().toLowerCase() ?? ''; + final nis = murid['nis']?.toString().toLowerCase() ?? ''; + return nama.contains(keyword) || nis.contains(keyword); + }).toList(); + + return Scaffold( + backgroundColor: const Color(0xFFF1F5F9), + appBar: AppBar( + elevation: 0, + backgroundColor: const Color(0xFF2563EB), + automaticallyImplyLeading: false, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(0), + child: Container( + height: 45, + width: double.infinity, + color: const Color(0xFF2563EB), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + namaKelas.isNotEmpty ? "Daftar Murid | $namaKelas" : "Daftar Murid", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + fontFamily: 'Inter', + ), + ), + const SizedBox(height: 4), + Text( + "$dataCount murid terdaftar", + style: const TextStyle( + fontSize: 11, + color: Colors.white70, + fontFamily: 'Inter', + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + onPressed: () { + setState(() => isLoading = true); + fetchData(); + }, + ), + ], + ), + ), + ), + ), + + body: Column( + children: [ + // SEARCH BAR + Container( + height: 70, + color: const Color(0xFF2563EB), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: isSearchActive + ? TextField( + autofocus: true, + onChanged: (value) { + setState(() => searchQuery = value); + }, + decoration: InputDecoration( + hintText: "Cari nama atau NIS...", + hintStyle: const TextStyle( + fontSize: 14, + color: Color(0xFFCCCCCC), + ), + filled: true, + fillColor: Colors.grey[200], + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + prefixIcon: IconButton( + icon: const Icon(Icons.arrow_back, size: 18), + onPressed: () { + setState(() { + isSearchActive = false; + searchQuery = null; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ) + : Row( + children: [ + Transform.translate( + offset: const Offset(-12, 0), + child: IconButton( + icon: const Icon(Icons.search, color: Colors.white), + onPressed: () { + setState(() { + isSearchActive = true; + }); + }, + ), + ), + Expanded( + child: Text( + namaSekolah, + textAlign: TextAlign.right, + style: const TextStyle( + fontFamily: "Inter", + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + + // CONTENT + Expanded( + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : _allMurid.isEmpty + ? const Center(child: Text('Belum ada murid di kelas Anda.')) + : muridList.isEmpty + ? const Center(child: Text('Pencarian tidak ditemukan.')) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: muridList.length, + itemBuilder: (context, index) { + final murid = muridList[index]; + final nama = murid['nama']?.toString() ?? 'Tanpa Nama'; + final nis = murid['nis']?.toString() ?? '-'; + + return CustomCard( + title: nama, + subtitle: 'NIS: $nis', + titleStyle: const TextStyle( + fontSize: 12, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + subtitleStyle: const TextStyle( + fontSize: 10, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + color: Color(0xFF64748B), + ), + backgroundColor: Colors.white, + borderColor: Colors.transparent, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DetailMuridPage(data: murid), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/dashboard/d_guru/dashboard/pengumuman.dart b/lib/dashboard/d_guru/dashboard/pengumuman.dart new file mode 100644 index 0000000..2f8fea8 --- /dev/null +++ b/lib/dashboard/d_guru/dashboard/pengumuman.dart @@ -0,0 +1,697 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/grup_sekolah_g.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/grup_kelas_g.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/chat_admin_g.dart'; +import 'package:apk/dashboard/d_guru/database/db_guru.dart'; +import 'package:apk/dashboard/d_guru/database/db_kelas.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/chat_private_g.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/grup_sekolah_n.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/grup_kelas_n.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/chat_private_n.dart'; +import 'package:apk/error_handler/connection.dart'; + +class Pengumuman extends StatefulWidget { + const Pengumuman({super.key}); + + @override + State createState() => _PengumumanState(); +} + +class _PengumumanState extends State { + final DbGuru _dbGuru = DbGuru(); + final DbKelas _dbKelas = DbKelas(); + bool _isLoading = true; + List> _listGuru = []; + List> _listMurid = []; + String? _loggedInGuruId; + String? _loggedInClassId; + String? _adminId; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + bool hasConnection = await ConnectionHandler.checkConnection(); + if (!hasConnection) { + if (mounted) { + ConnectionHandler.showNoConnectionDialog( + context: context, + onRetry: () => _loadData(), + ); + setState(() => _isLoading = false); + } + return; + } + try { + final supabase = Supabase.instance.client; + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nik = prefs.getString('guru_nik'); + + if (userId == null || nik == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + _adminId = userId; + + // 1. Dapatkan info guru yang login (sama persis seperti db_kelas) + final guruData = await supabase + .from('guru') + .select('id_tabel, id_class') + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + + if (guruData == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + final guruIdTabel = guruData['id_tabel']; + final guruIdClass = guruData['id_class']; + + // 2. Kumpulkan semua ID Kelas (UUID) — pola dari db_kelas + Set validClassIds = {}; + if (guruIdClass != null) { + validClassIds.add(guruIdClass.toString()); + } + + final classDataList = await supabase + .from('class_name') + .select('id_tabel, id_class') + .eq('id_guru', guruIdTabel); + + for (var c in classDataList) { + validClassIds.add(c['id_tabel'].toString()); + } + + if (validClassIds.isEmpty) { + if (mounted) { + setState(() { + _listGuru = []; + _listMurid = []; + _isLoading = false; + }); + } + return; + } + + // 3. Ambil guru yang id_class-nya ada di validClassIds (langsung dari DB) + final guruInClass = await supabase + .from('guru') + .select('id_tabel, nik, name, bidang, wali, id_class') + .inFilter('id_class', validClassIds.toList()) + .or('status_akun.is.null,status_akun.eq.true'); + + // Filter: buang diri sendiri + final List> filteredGuru = []; + for (var g in guruInClass) { + if (g['id_tabel'].toString() != guruIdTabel.toString()) { + filteredGuru.add(g); + } + } + + // 4. Ambil murid langsung — pola dari db_kelas.getMuridForGuru() + Map> allMurid = {}; + + // Relasi A: Murid yang id_class-nya merujuk ke validClassIds + final muridListA = await supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class') + .or('status_akun.is.null,status_akun.eq.true') + .inFilter('id_class', validClassIds.toList()); + + for (var m in muridListA) { + allMurid[m['id_tabel'].toString()] = m; + } + + // Relasi B: Dari id_murid di tabel class_name + final classMuridRows = await supabase + .from('class_name') + .select('id_murid') + .inFilter('id_tabel', validClassIds.toList()) + .not('id_murid', 'is', null); + + List muridIdsFromClass = classMuridRows + .map((r) => r['id_murid'].toString()) + .where((id) => id != 'null') + .toList(); + + if (muridIdsFromClass.isNotEmpty) { + final muridListB = await supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class') + .or('status_akun.is.null,status_akun.eq.true') + .inFilter('id_tabel', muridIdsFromClass); + + for (var m in muridListB) { + allMurid[m['id_tabel'].toString()] = m; + } + } + + final finalMuridList = allMurid.values.toList(); + finalMuridList.sort((a, b) => + (a['nama']?.toString() ?? '').compareTo(b['nama']?.toString() ?? '')); + + if (mounted) { + setState(() { + _listGuru = filteredGuru; + _listMurid = finalMuridList; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('Error _loadData pengumuman: $e'); + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 20, + title: const Text('Pengumuman Sekolah'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + actions: [ + // IconButton( + // icon: const Icon(Icons.notifications_active_outlined, color: Colors.white), + // onPressed: () { + // Navigator.push( + // context, + // MaterialPageRoute(builder: (context) => const PreviewPesan()), + // ); + // }, + // ), + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + onPressed: () { + setState(() { + _isLoading = true; + }); + _loadData(); + }, + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.symmetric(vertical: 10.0), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder( + valueListenable: GrupSekolahNotification.hasUnread, + builder: (context, hasUnread, child) { + return Stack( + children: [ + CustomCard( + title: 'Grup Sekolah', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Forum', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Informasi & Diskusi', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + GrupSekolahNotification.setUnread(false); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GrupSekolahG()), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder( + valueListenable: GrupKelasNotification.hasUnread, + builder: (context, hasUnread, child) { + return Stack( + children: [ + CustomCard( + title: 'Grup Kelas', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Kelas', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Diskusi Khusus Kelas Anda', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + GrupKelasNotification.setUnread(false); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GrupKelasPageG()), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder>( + valueListenable: ChatPrivateNotification.unreadSenderIds, + builder: (context, unreadSenders, child) { + final bool hasUnread = _adminId != null && unreadSenders.contains(_adminId); + return Stack( + children: [ + CustomCard( + title: 'Admin Sekolah', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Bantuan', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Hubungi Admin Sekolah', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + if (_adminId != null) { + ChatPrivateNotification.setSenderUnread(_adminId!, false); + } + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ChatAdminPageG()), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text('List Guru', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, fontFamily: 'Inter')), + ), + const SizedBox(height: 10), + ...(_listGuru ?? []).map((guru) { + final nama = guru['name'] ?? '-'; + final isWali = guru['wali'] == true; + final roleText = isWali ? 'Wali Kelas' : 'Guru Mapel'; + + final classNameObj = guru['class_name']; + String kelas = "Belum ada kelas"; + if (classNameObj != null) { + if (classNameObj is String) { + kelas = classNameObj; + } else if (classNameObj is List && classNameObj.isNotEmpty) { + kelas = classNameObj[0]['name_class']?.toString() ?? "Belum ada kelas"; + } else if (classNameObj is Map) { + kelas = classNameObj['name_class']?.toString() ?? "Belum ada kelas"; + } + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder>( + valueListenable: ChatPrivateNotification.unreadSenderIds, + builder: (context, unreadSenders, child) { + final String guruId = guru['id_tabel'].toString(); + final bool hasUnread = unreadSenders.contains(guruId); + return Stack( + children: [ + CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + roleText, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + kelas, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + ChatPrivateNotification.setSenderUnread(guruId, false); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPrivateG( + receiverId: guruId, + receiverType: 'guru', + receiverName: nama, + ), + ), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ); + }).toList(), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text('List Murid', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, fontFamily: 'Inter')), + ), + const SizedBox(height: 10), + ...(_listMurid ?? []).map((murid) { + final nama = murid['nama'] ?? '-'; + + final classNameObj = murid['class_name']; + String kelas = "Belum ada kelas"; + if (classNameObj != null) { + if (classNameObj is String) { + kelas = classNameObj; + } else if (classNameObj is List && classNameObj.isNotEmpty) { + kelas = classNameObj[0]['name_class']?.toString() ?? "Belum ada kelas"; + } else if (classNameObj is Map) { + kelas = classNameObj['name_class']?.toString() ?? "Belum ada kelas"; + } + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: ValueListenableBuilder>( + valueListenable: ChatPrivateNotification.unreadSenderIds, + builder: (context, unreadSenders, child) { + final String muridId = murid['id_tabel'].toString(); + final bool hasUnread = unreadSenders.contains(muridId); + return Stack( + children: [ + CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Siswa', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + kelas, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + ChatPrivateNotification.setSenderUnread(muridId, false); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPrivateG( + receiverId: muridId, + receiverType: 'murid', + receiverName: nama, + ), + ), + ); + }, + ), + if (hasUnread) + Positioned( + top: 12, + right: 16, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }, + ), + ); + }).toList(), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_guru/database/auth_guru.dart b/lib/dashboard/d_guru/database/auth_guru.dart new file mode 100644 index 0000000..5c4d113 --- /dev/null +++ b/lib/dashboard/d_guru/database/auth_guru.dart @@ -0,0 +1,50 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/fcm_notif.dart'; + +class AuthGuru { + final _supabase = Supabase.instance.client; + + Future> signInGuru(String email, String nik) async { + try { + // 1. Resolve user_id (UUID) from the admin's email in user_admin + final adminRes = await _supabase + .from('user_admin') + .select('id') + .eq('email', email.trim()) + .maybeSingle(); + + if (adminRes == null) { + throw Exception('Email admin tidak ditemukan.'); + } + + final userId = adminRes['id'].toString(); + + // 2. Query guru with the resolved user_id and nik + final response = await _supabase + .from('guru') + .select() + .eq('user_id', userId) + .eq('nik', nik) + .single(); + + if (response['status_akun'] == false) { + throw Exception('Akun guru telah dinonaktifkan.'); + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_role', 'guru'); + await prefs.setString('guru_nik', nik); + await prefs.setString('user_id_admin', userId); + await prefs.setString('guru_nama', response['name']?.toString() ?? ''); + await prefs.setString('guru_id_tabel', response['id_tabel'].toString()); + return response; + } catch (e) { + if (e.toString().contains('dinonaktifkan')) { + rethrow; + } + throw Exception('Login gagal: Periksa Email Admin dan NIK'); + } + } +} diff --git a/lib/dashboard/d_guru/database/db_absensi.dart b/lib/dashboard/d_guru/database/db_absensi.dart new file mode 100644 index 0000000..3a17b77 --- /dev/null +++ b/lib/dashboard/d_guru/database/db_absensi.dart @@ -0,0 +1,1197 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'db_kelas.dart'; + +import 'holiday_service.dart'; + +class DbAbsensi { + final SupabaseClient _supabase = Supabase.instance.client; + + Future>> getAbsensi(String nisOrId) async { + try { + String idMurid = nisOrId; + String? studentNis; + String? userId; + String? studentName; + + final isNumeric = RegExp(r'^\d+$').hasMatch(nisOrId); + if (isNumeric) { + final res = await _supabase + .from('murid') + .select('id_tabel, user_id, nis, nama') + .eq('nis', nisOrId) + .maybeSingle(); + if (res != null) { + idMurid = res['id_tabel'].toString(); + userId = res['user_id']?.toString(); + studentNis = res['nis']?.toString(); + studentName = res['nama']?.toString(); + } + } else { + final res = await _supabase + .from('murid') + .select('user_id, nis, nama') + .eq('id_tabel', nisOrId) + .maybeSingle(); + if (res != null) { + userId = res['user_id']?.toString(); + studentNis = res['nis']?.toString(); + studentName = res['nama']?.toString(); + } + } + + final response = await _supabase + .from('absen') + .select('*, leave_request:id_izin(*), access_bridge:id_access(*, output_alat(*)), murid:id_murid(*), pengumuman:id_pengumuman(*)') + .eq('id_murid', idMurid) + .order('date', ascending: false); + + // Fetch verified access records (processed on DB side, read via query relations) + List> accessResponse = []; + + // Fetch approved leave requests + List> leaveResponse = []; + if (idMurid.isNotEmpty) { + try { + final res = await _supabase + .from('leave_request') + .select('*') + .eq('id_murid', idMurid) + .eq('verif', true); + leaveResponse = List>.from(res); + } catch (_) {} + } + + final Map> consolidatedLeave = {}; + for (var req in leaveResponse) { + final startStr = req['tanggal_mulai']?.toString(); + final endStr = req['tanggal_selesai']?.toString(); + if (startStr == null || endStr == null) continue; + try { + final start = DateTime.parse(startStr); + final end = DateTime.parse(endStr); + for (int d = 0; d <= end.difference(start).inDays; d++) { + final currentDate = start.add(Duration(days: d)); + final dateKey = DateFormat('yyyy-MM-dd').format(currentDate); + consolidatedLeave[dateKey] = { + 'id_tabel': req['id_tabel']?.toString(), + 'ket_opsi': req['ket_opsi']?.toString() ?? 'izin', + }; + } + } catch (_) {} + } + + final Map> consolidatedAccess = {}; + + for (var row in accessResponse) { + if (row['created_at'] == null) continue; + + DateTime? parsedDate; + try { + parsedDate = DateTime.parse(row['created_at'].toString()).toLocal(); + } catch (_) {} + if (parsedDate == null) continue; + + final dateKey = DateFormat('yyyy-MM-dd').format(parsedDate); + + final rawMasuk = row['data_masuk']; + + final formattedMasuk = _formatTime(rawMasuk) ?? '07:00'; + + if (!consolidatedAccess.containsKey(dateKey)) { + consolidatedAccess[dateKey] = { + 'date': dateKey, + 'id_tabel': row['id_tabel']?.toString(), + 'id_murid': idMurid, + 'id_alat': row['id_alat']?.toString(), + 'user_id': row['user_id'], + 'masuk': formattedMasuk, + 'keluar': null, + 'output_alat': row['output_alat'], + }; + } else { + final existing = consolidatedAccess[dateKey]!; + if (existing['masuk'] == null) { + existing['masuk'] = formattedMasuk; + } + } + } + + final Map studentMuridMap = { + 'id_tabel': idMurid, + 'user_id': userId, + 'nis': studentNis, + 'nama': studentName, + }; + + return _mergeAbsenWithAccess(response, consolidatedAccess, idMurid, studentMuridMap, consolidatedLeave); + } catch (e) { + throw Exception('Gagal memuat data absensi: $e'); + } + } + + Future>> getAbsensiByMonth(List listNis, String yearMonth) async { + if (listNis.isEmpty) return []; + try { + final muridRes = await _supabase + .from('murid') + .select('id_tabel, user_id, nis, nama') + .inFilter('nis', listNis); + + final listIdMurid = muridRes.map((m) => m['id_tabel'].toString()).toList(); + final listUserIds = muridRes.map((m) => m['user_id']?.toString()).whereType().toList(); + if (listIdMurid.isEmpty) return []; + + // Parse year and month to construct date range filtering + final parts = yearMonth.split('-'); + final year = int.parse(parts[0]); + final month = int.parse(parts[1]); + final startDate = '$yearMonth-01'; + final lastDay = DateTime(year, month + 1, 0).day; + final lastDayStr = lastDay < 10 ? '0$lastDay' : '$lastDay'; + final endDate = '$year-${parts[1]}-$lastDayStr'; + + final response = await _supabase + .from('absen') + .select('*, leave_request:id_izin(*), access_bridge:id_access(*, output_alat(*)), murid:id_murid(*), pengumuman:id_pengumuman(*)') + .inFilter('id_murid', listIdMurid) + .gte('date', startDate) + .lte('date', endDate) + .order('date', ascending: true); + + final Map> muridMapById = {}; + final Map studentNamesById = {}; + for (var m in muridRes) { + final id = m['id_tabel'].toString(); + muridMapById[id] = Map.from(m); + studentNamesById[id] = m['nama']?.toString() ?? ''; + } + + // Fetch verified access records directly from access_bridge (synced/processed on DB side) + List> accessResponse = []; + try { + final res = await _supabase + .from('access_bridge') + .select('*, output_alat(*)') + .inFilter('id_murid', listIdMurid); + accessResponse = List>.from(res); + } catch (_) {} + + // Fetch approved leave requests + List> leaveResponse = []; + try { + final res = await _supabase + .from('leave_request') + .select('*') + .inFilter('id_murid', listIdMurid) + .eq('verif', true); + leaveResponse = List>.from(res); + } catch (_) {} + + final Map> consolidatedLeave = {}; + for (var req in leaveResponse) { + final studentId = req['id_murid']?.toString(); + final startStr = req['tanggal_mulai']?.toString(); + final endStr = req['tanggal_selesai']?.toString(); + if (studentId == null || startStr == null || endStr == null) continue; + + try { + final start = DateTime.parse(startStr); + final end = DateTime.parse(endStr); + for (int d = 0; d <= end.difference(start).inDays; d++) { + final currentDate = start.add(Duration(days: d)); + final dateKey = DateFormat('yyyy-MM-dd').format(currentDate); + final compositeKey = '${studentId}_$dateKey'; + consolidatedLeave[compositeKey] = { + 'id_tabel': req['id_tabel']?.toString(), + 'ket_opsi': req['ket_opsi']?.toString() ?? 'izin', + }; + } + } catch (_) {} + } + + // Group access logs by student (NIS) and date + final Map> consolidatedAccess = {}; + for (var row in accessResponse) { + if (row['created_at'] == null) continue; + + DateTime? parsedDate; + try { + parsedDate = DateTime.parse(row['created_at'].toString()).toLocal(); + } catch (_) {} + if (parsedDate == null) continue; + + final matchedStudentId = row['id_murid']?.toString(); + if (matchedStudentId == null) continue; + + final dateKey = DateFormat('yyyy-MM-dd').format(parsedDate); + final compositeKey = '${matchedStudentId}_$dateKey'; + + final rawMasuk = row['data_masuk']; + + final formattedMasuk = _formatTime(rawMasuk) ?? '07:00'; + + if (!consolidatedAccess.containsKey(compositeKey)) { + consolidatedAccess[compositeKey] = { + 'date': dateKey, + 'id_tabel': row['id_tabel']?.toString(), + 'id_murid': matchedStudentId, + 'id_alat': row['id_alat']?.toString(), + 'user_id': row['user_id'], + 'masuk': formattedMasuk, + 'keluar': null, + 'output_alat': row['output_alat'], + }; + } else { + final existing = consolidatedAccess[compositeKey]!; + if (existing['masuk'] == null) { + existing['masuk'] = formattedMasuk; + } + } + } + + // Merge absen with consolidatedAccess & consolidatedLeave + final List> mergedList = []; + final Set processedCompositeKeys = {}; + + for (var item in response) { + final row = Map.from(item); + final dateKey = row['date']?.toString(); + final studentId = row['id_murid']?.toString(); + + if (dateKey != null && studentId != null) { + final compositeKey = '${studentId}_$dateKey'; + processedCompositeKeys.add(compositeKey); + + if (consolidatedAccess.containsKey(compositeKey)) { + final access = consolidatedAccess[compositeKey]!; + row['masuk'] = access['masuk'] ?? row['masuk']; + row['keluar'] = access['keluar'] ?? row['keluar']; + row['access_bridge'] = { + 'id_tabel': access['id_tabel'] ?? '', + 'id_alat': access['id_alat'], + 'id_murid': access['id_murid'], + 'user_id': access['user_id'], + 'data_masuk': access['masuk'], + 'data_keluar': access['keluar'], + 'output_alat': access['output_alat'], + }; + } else if (row['access_bridge'] != null) { + final accessBridge = row['access_bridge'] as Map; + final rawMasuk = accessBridge['data_masuk'] ?? accessBridge['masuk']; + final rawKeluar = accessBridge['data_keluar'] ?? accessBridge['keluar']; + + final formattedMasuk = _formatTime(rawMasuk) ?? '07:00'; + final formattedKeluar = _formatTime(rawKeluar); + + row['masuk'] = formattedMasuk; + if (formattedKeluar != null) row['keluar'] = formattedKeluar; + } + + if (row['leave_request'] == null && consolidatedLeave.containsKey(compositeKey)) { + final leave = consolidatedLeave[compositeKey]!; + row['leave_request'] = { + 'id_tabel': leave['id_tabel'], + 'ket_opsi': leave['ket_opsi'], + 'verif': true, + }; + } + } + mergedList.add(row); + } + + // Add synthesized ones from consolidatedAccess + consolidatedAccess.forEach((compositeKey, access) { + if (!processedCompositeKeys.contains(compositeKey)) { + processedCompositeKeys.add(compositeKey); + final studentId = access['id_murid']?.toString(); + + Map? leaveRequestData; + if (consolidatedLeave.containsKey(compositeKey)) { + final leave = consolidatedLeave[compositeKey]!; + leaveRequestData = { + 'id_tabel': leave['id_tabel'], + 'ket_opsi': leave['ket_opsi'], + 'verif': true, + }; + } + + final synthRow = { + 'date': access['date'], + 'id_murid': studentId, + 'masuk': access['masuk'], + 'keluar': access['keluar'], + 'access_bridge': { + 'id_tabel': access['id_tabel'] ?? '', + 'id_alat': access['id_alat'], + 'id_murid': access['id_murid'], + 'user_id': access['user_id'], + 'data_masuk': access['masuk'], + 'data_keluar': access['keluar'], + 'output_alat': access['output_alat'], + }, + 'murid': studentId != null ? muridMapById[studentId] : null, + 'leave_request': leaveRequestData, + 'pengumuman': null, + }; + mergedList.add(synthRow); + } + }); + + // Add synthesized ones from consolidatedLeave + consolidatedLeave.forEach((compositeKey, leave) { + if (!processedCompositeKeys.contains(compositeKey)) { + processedCompositeKeys.add(compositeKey); + final parts = compositeKey.split('_'); + final studentId = parts.first; + final dateKey = parts.last; + + final synthRow = { + 'date': dateKey, + 'id_murid': studentId, + 'masuk': null, + 'keluar': null, + 'access_bridge': null, + 'murid': muridMapById[studentId], + 'leave_request': { + 'id_tabel': leave['id_tabel'], + 'ket_opsi': leave['ket_opsi'], + 'verif': true, + }, + 'pengumuman': null, + }; + mergedList.add(synthRow); + } + }); + + // Sort by date ascending + mergedList.sort((a, b) { + final dateA = a['date']?.toString() ?? ''; + final dateB = b['date']?.toString() ?? ''; + return dateA.compareTo(dateB); + }); + + return mergedList; + } catch (e) { + throw Exception('Gagal memuat data absensi kelas: $e'); + } + } + + List> _mergeAbsenWithAccess( + List absenResponse, + Map> consolidatedAccess, + String idMurid, + Map studentMuridMap, + Map> consolidatedLeave) { + + final List> mergedList = []; + final Set processedDates = {}; + + for (var item in absenResponse) { + final row = Map.from(item); + final dateKey = row['date']?.toString(); + + if (dateKey != null) { + processedDates.add(dateKey); + + if (consolidatedAccess.containsKey(dateKey)) { + final access = consolidatedAccess[dateKey]!; + + row['masuk'] = access['masuk'] ?? row['masuk']; + row['keluar'] = access['keluar'] ?? row['keluar']; + + row['access_bridge'] = { + 'id_tabel': access['id_tabel'] ?? '', + 'id_alat': access['id_alat'], + 'id_murid': access['id_murid'], + 'user_id': access['user_id'], + 'data_masuk': access['masuk'], + 'data_keluar': access['keluar'], + 'output_alat': access['output_alat'], + }; + } else if (row['access_bridge'] != null) { + final accessBridge = row['access_bridge'] as Map; + final rawMasuk = accessBridge['data_masuk'] ?? accessBridge['masuk']; + final rawKeluar = accessBridge['data_keluar'] ?? accessBridge['keluar']; + + final formattedMasuk = _formatTime(rawMasuk) ?? '07:00'; + final formattedKeluar = _formatTime(rawKeluar); + + row['masuk'] = formattedMasuk; + if (formattedKeluar != null) row['keluar'] = formattedKeluar; + } + + if (row['leave_request'] == null && consolidatedLeave.containsKey(dateKey)) { + final leave = consolidatedLeave[dateKey]!; + row['leave_request'] = { + 'id_tabel': leave['id_tabel'], + 'ket_opsi': leave['ket_opsi'], + 'verif': true, + }; + } + } + mergedList.add(row); + } + + consolidatedAccess.forEach((dateKey, access) { + if (!processedDates.contains(dateKey)) { + processedDates.add(dateKey); + + Map? leaveRequestData; + if (consolidatedLeave.containsKey(dateKey)) { + final leave = consolidatedLeave[dateKey]!; + leaveRequestData = { + 'id_tabel': leave['id_tabel'], + 'ket_opsi': leave['ket_opsi'], + 'verif': true, + }; + } + + final synthRow = { + 'date': dateKey, + 'id_murid': idMurid, + 'masuk': access['masuk'], + 'keluar': access['keluar'], + 'access_bridge': { + 'id_tabel': access['id_tabel'] ?? '', + 'id_alat': access['id_alat'], + 'id_murid': access['id_murid'], + 'user_id': access['user_id'], + 'data_masuk': access['masuk'], + 'data_keluar': access['keluar'], + 'output_alat': access['output_alat'], + }, + 'murid': studentMuridMap, + 'leave_request': leaveRequestData, + 'pengumuman': null, + }; + mergedList.add(synthRow); + } + }); + + consolidatedLeave.forEach((dateKey, leave) { + if (!processedDates.contains(dateKey)) { + processedDates.add(dateKey); + final synthRow = { + 'date': dateKey, + 'id_murid': idMurid, + 'masuk': null, + 'keluar': null, + 'access_bridge': null, + 'murid': studentMuridMap, + 'leave_request': { + 'id_tabel': leave['id_tabel'], + 'ket_opsi': leave['ket_opsi'], + 'verif': true, + }, + 'pengumuman': null, + }; + mergedList.add(synthRow); + } + }); + + mergedList.sort((a, b) { + final dateA = a['date']?.toString() ?? ''; + final dateB = b['date']?.toString() ?? ''; + return dateB.compareTo(dateA); + }); + + return mergedList; + } + + static String? _formatTime(dynamic timeVal) { + if (timeVal == null) return null; + final str = timeVal.toString().trim(); + if (str.isEmpty) return null; + + // Check if it is a full ISO8601 DateTime string + final parsed = DateTime.tryParse(str); + if (parsed != null) { + return DateFormat('HH:mm').format(parsed.toLocal()); + } + + final parts = str.split(':'); + if (parts.length >= 2) { + var hour = parts[0]; + if (hour.contains('T')) { + hour = hour.split('T').last; + } + hour = hour.padLeft(2, '0'); + var minute = parts[1]; + if (minute.length > 2) { + minute = minute.substring(0, 2); + } + minute = minute.padLeft(2, '0'); + return "$hour:$minute"; + } + return str; + } + + static String getStatus(Map row) { + // 0. Cek MANUAL override terlebih dahulu agar memiliki prioritas tertinggi + if (row['Null_data']?.toString() == 'Belum Absen') return 'Belum Absen'; + if (row['ket_sakit']?.toString().startsWith('MANUAL_') ?? false) return 'Sakit'; + if (row['ket_izin']?.toString().startsWith('MANUAL_') ?? false) return 'Izin'; + if (row['ket_libur']?.toString().startsWith('MANUAL_') ?? false) return 'Libur'; + if (row['ket_alpha']?.toString().startsWith('MANUAL_') ?? false) return 'Alpha'; + if (row['ket_hadir']?.toString().startsWith('MANUAL_') ?? false) { + final kh = row['ket_hadir'].toString(); + if (kh.toLowerCase().contains('terlambat')) return 'Terlambat'; + return 'Hadir'; + } + + // 1. Izin dan Sakit dari foreign key id_izin (leave_request) yang verify true + if (row['leave_request'] != null && row['leave_request']['verif'] == true) { + final ketOpsi = (row['leave_request']['ket_opsi'] ?? '').toString().toLowerCase(); + if (ketOpsi == 'sakit') { + return 'Sakit'; + } + if (ketOpsi == 'izin') { + return 'Izin'; + } + + // Fallback ke keterangan jika ket_opsi kosong/null + final ket = (row['leave_request']['keterangan'] ?? '').toString().toLowerCase(); + if (ket.contains('sakit')) { + return 'Sakit'; + } + return 'Izin'; + } + + // Fallback izin/sakit langsung dari tabel absen jika ada data manual + if (row['ket_sakit'] != null && row['ket_sakit'].toString().isNotEmpty) return 'Sakit'; + if (row['ket_izin'] != null && row['ket_izin'].toString().isNotEmpty) return 'Izin'; + + // 2. Libur diambil dari date hari minggu dan foreign key id_pengumuman dengan keterangan libur + final dateStr = row['date']?.toString() ?? ''; + bool isSundayOrHoliday = false; + if (dateStr.isNotEmpty) { + try { + final date = DateTime.parse(dateStr); + if (date.weekday == DateTime.sunday || HolidayService.isHoliday(date)) { + isSundayOrHoliday = true; + } + } catch (_) {} + } + + bool isPengumumanLibur = false; + if (row['pengumuman'] != null) { + final title = (row['pengumuman']['title'] ?? '').toString().toLowerCase(); + final keterangan = (row['pengumuman']['keterangan'] ?? '').toString().toLowerCase(); + if (title.contains('libur') || keterangan.contains('libur')) { + isPengumumanLibur = true; + } + } + + if (isSundayOrHoliday || isPengumumanLibur) { + return 'Libur'; + } + if (row['ket_libur'] != null && row['ket_libur'].toString().isNotEmpty) return 'Libur'; + + // 3. Hadir / Terlambat diambil dari foreign key id_access (access_bridge) + if (row['access_bridge'] != null) { + final accessBridge = row['access_bridge']; + + // Perform security check: match nama_user (from output_alat) with murid.nama + final outputAlat = accessBridge['output_alat']; + final namaUser = outputAlat != null ? outputAlat['nama_user']?.toString() : null; + final studentName = row['murid'] != null ? row['murid']['nama']?.toString() : null; + + bool nameMatches = true; + if (studentName != null && namaUser != null) { + if (namaUser.trim().toLowerCase() != studentName.trim().toLowerCase()) { + nameMatches = false; + } + } + + if (nameMatches) { + final rawMasuk = accessBridge['data_masuk']; + final formattedMasuk = _formatTime(rawMasuk) ?? '07:00'; + + // Hanya dianggap hadir jika ada data masuk (absen sekali) + if (formattedMasuk.trim().isNotEmpty) { + final parts = formattedMasuk.split(':'); + if (parts.length >= 2) { + final hour = int.tryParse(parts[0]) ?? 0; + final minute = int.tryParse(parts[1]) ?? 0; + if (hour > 7 || (hour == 7 && minute > 0)) { + return 'Terlambat'; + } + } + return 'Hadir'; + } + } + } + + // Fallback ket_hadir / masuk / terlambat dari tabel absen langsung + if (row['ket_hadir'] != null && row['ket_hadir'].toString().isNotEmpty) { + final kh = row['ket_hadir'].toString(); + if (kh.toLowerCase().contains('terlambat')) return 'Terlambat'; + return 'Hadir'; + } + if (row['terlambat'] != null && row['terlambat'].toString().isNotEmpty) return 'Terlambat'; + if (row['masuk'] != null && row['masuk'].toString().isNotEmpty) return 'Hadir'; + + // 4. Alpha + if (row['ket_alpha'] != null && row['ket_alpha'].toString().isNotEmpty) return 'Alpha'; + + return 'Belum Absen'; + } + + // === MONTHLY REKAP METHODS === + + /// Generate rekap bulanan: buat row di tabel absen untuk semua murid × semua hari + /// dengan absensi_baru = 'REKAP_{yearMonth}'. + /// Menerima [students] langsung agar tidak fetch ulang & filter per kelas benar. + /// Hanya insert baris yang belum ada (per-murid check). + Future generateMonthlyRekap( + String yearMonth, + List> students, + ) async { + if (students.isEmpty) return; + + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + if (userId == null) throw Exception('Sesi tidak valid.'); + + final rekapKey = 'REKAP_$yearMonth'; + + // Parse year & month + final parts = yearMonth.split('-'); + final year = int.parse(parts[0]); + final month = int.parse(parts[1]); + final daysInMonth = DateTime(year, month + 1, 0).day; + + // Kumpulkan id_murid yang akan diproses + final listIdMurid = students + .map((m) => m['id_tabel']?.toString()) + .where((id) => id != null) + .cast() + .toList(); + if (listIdMurid.isEmpty) return; + + // Cek murid mana yang sudah punya baris rekap untuk bulan ini + final existingRows = await _supabase + .from('absen') + .select('id_murid') + .eq('absensi_baru', rekapKey) + .inFilter('id_murid', listIdMurid); + + final existingMuridIds = existingRows + .map((r) => r['id_murid']?.toString()) + .whereType() + .toSet(); + + // Hanya insert untuk murid yang belum punya rekap + final studentsToInsert = students + .where((m) => !existingMuridIds.contains(m['id_tabel']?.toString())) + .toList(); + + if (studentsToInsert.isEmpty) return; + + // Generate rows: 1 row per murid per hari + final List> rowsToInsert = []; + for (var murid in studentsToInsert) { + final String? createdAtStr = murid['created_at']?.toString(); + DateTime? joinDate; + if (createdAtStr != null) { + final parsed = DateTime.tryParse(createdAtStr); + if (parsed != null) { + joinDate = parsed.toLocal(); + } + } + + for (int day = 1; day <= daysInMonth; day++) { + final date = DateTime(year, month, day); + + // Skip jika tanggal saat ini sama dengan atau sebelum tanggal murid bergabung + // Absen akan mulai dihitung pada H+1 setelah ditambahkan ke kelas + if (joinDate != null) { + final joinDateOnly = DateTime(joinDate.year, joinDate.month, joinDate.day); + if (date.isBefore(joinDateOnly) || date.isAtSameMomentAs(joinDateOnly)) { + continue; + } + } + + final formattedDate = DateFormat('yyyy-MM-dd').format(date); + final isSundayOrHoliday = + date.weekday == DateTime.sunday || HolidayService.isHoliday(date); + + rowsToInsert.add({ + 'date': formattedDate, + 'user_id': userId, + 'id_murid': murid['id_tabel'], + 'absensi_baru': rekapKey, + if (isSundayOrHoliday) 'ket_libur': 'Libur', + }); + } + } + + // Batch insert + if (rowsToInsert.isNotEmpty) { + await _supabase.from('absen').insert(rowsToInsert); + } + } + + /// Sync data rekap bulanan dengan access_bridge & leave_request. + /// Memperbarui row yang sudah ada di tabel absen. + /// Menerima [listIdMurid] agar hanya sync murid dari kelas yang sedang dilihat. + Future syncMonthlyRekap( + String yearMonth, + List listIdMurid, + ) async { + if (listIdMurid.isEmpty) return; + + final rekapKey = 'REKAP_$yearMonth'; + final parts = yearMonth.split('-'); + final year = int.parse(parts[0]); + final month = int.parse(parts[1]); + + // 1. Ambil semua row rekap bulan ini, difilter per murid kelas ini + final rekapRows = await _supabase + .from('absen') + .select('id_tabel, date, id_murid, ket_libur, ket_hadir, ket_izin, ket_sakit, ket_alpha, Null_data') + .eq('absensi_baru', rekapKey) + .inFilter('id_murid', listIdMurid); + + if (rekapRows.isEmpty) return; + + // Build lookup: compositeKey (id_murid_date) -> row + final Map> rekapLookup = {}; + for (var row in rekapRows) { + final idMurid = row['id_murid']?.toString(); + final date = row['date']?.toString(); + if (idMurid == null || date == null) continue; + rekapLookup['${idMurid}_$date'] = Map.from(row); + } + + if (listIdMurid.isEmpty) return; + + // 2. Ambil info murid (untuk nama, untuk matching access) + final muridRes = await _supabase + .from('murid') + .select('id_tabel, user_id, nis, nama') + .inFilter('id_tabel', listIdMurid); + + final Map studentNamesById = {}; + final List listUserIds = []; + for (var m in muridRes) { + final id = m['id_tabel'].toString(); + studentNamesById[id] = m['nama']?.toString() ?? ''; + final uid = m['user_id']?.toString(); + if (uid != null) listUserIds.add(uid); + } + + // 3. Sync dari access_bridge (kehadiran via scan alat) + List> accessResponse = []; + try { + final res = await _supabase + .from('access_bridge') + .select('*, output_alat(*)') + .inFilter('id_murid', listIdMurid); + accessResponse = List>.from(res); + } catch (_) {} + + // Build access lookup: compositeKey -> access data (hanya untuk bulan ini) + final Map> accessLookup = {}; + for (var row in accessResponse) { + if (row['created_at'] == null) continue; + DateTime? parsedDate; + try { + parsedDate = DateTime.parse(row['created_at'].toString()).toLocal(); + } catch (_) {} + if (parsedDate == null) continue; + // Filter hanya bulan ini + if (parsedDate.year != year || parsedDate.month != month) continue; + + final idMurid = row['id_murid']?.toString(); + if (idMurid == null) continue; + final dateKey = DateFormat('yyyy-MM-dd').format(parsedDate); + final compositeKey = '${idMurid}_$dateKey'; + + if (!accessLookup.containsKey(compositeKey)) { + accessLookup[compositeKey] = row; + } + } + + // 4. Sync dari leave_request (izin/sakit yang verified) + List> leaveResponse = []; + try { + final res = await _supabase + .from('leave_request') + .select('*') + .inFilter('id_murid', listIdMurid) + .eq('verif', true); + leaveResponse = List>.from(res); + } catch (_) {} + + // Build leave lookup: compositeKey -> leave data (hanya untuk bulan ini) + final Map> leaveLookup = {}; + for (var req in leaveResponse) { + final studentId = req['id_murid']?.toString(); + final startStr = req['tanggal_mulai']?.toString(); + final endStr = req['tanggal_selesai']?.toString(); + if (studentId == null || startStr == null || endStr == null) continue; + try { + final start = DateTime.parse(startStr); + final end = DateTime.parse(endStr); + for (int d = 0; d <= end.difference(start).inDays; d++) { + final currentDate = start.add(Duration(days: d)); + // Filter hanya bulan ini + if (currentDate.year != year || currentDate.month != month) continue; + final dateKey = DateFormat('yyyy-MM-dd').format(currentDate); + final compositeKey = '${studentId}_$dateKey'; + leaveLookup[compositeKey] = req; + } + } catch (_) {} + } + + // 5. Muat set id_tabel yang sudah di-override manual + final Set manualOverrideIds = await _getManualOverrideIds(); + + // 6. Update setiap row rekap berdasarkan data access & leave + final now = DateTime.now(); + final List> updates = []; + + for (var entry in rekapLookup.entries) { + final compositeKey = entry.key; + final row = entry.value; + final idTabel = row['id_tabel'].toString(); + final isLibur = row['ket_libur'] != null && row['ket_libur'].toString().isNotEmpty; + + // Skip hari libur + if (isLibur) continue; + + // Skip row yang sudah di-override manual oleh guru (tersimpan di SharedPreferences atau berawalan MANUAL_ / Belum Absen di DB) + final isManualDb = + (row['Null_data']?.toString() == 'Belum Absen') || + (row['ket_hadir']?.toString().startsWith('MANUAL_') ?? false) || + (row['ket_izin']?.toString().startsWith('MANUAL_') ?? false) || + (row['ket_sakit']?.toString().startsWith('MANUAL_') ?? false) || + (row['ket_alpha']?.toString().startsWith('MANUAL_') ?? false) || + (row['ket_libur']?.toString().startsWith('MANUAL_') ?? false); + final isManualOverride = manualOverrideIds.contains(idTabel); + if (isManualOverride || isManualDb) continue; + + final Map updateData = { + 'ket_hadir': null, + 'ket_izin': null, + 'ket_sakit': null, + 'ket_alpha': null, + }; + + // Prioritas: leave_request > access_bridge > alpha (jika tanggal sudah lewat) + if (leaveLookup.containsKey(compositeKey)) { + final leave = leaveLookup[compositeKey]!; + final ketOpsi = (leave['ket_opsi'] ?? 'izin').toString().toLowerCase(); + if (ketOpsi == 'sakit') { + updateData['ket_sakit'] = 'Sakit'; + } else { + updateData['ket_izin'] = 'Izin'; + } + } else if (accessLookup.containsKey(compositeKey)) { + final access = accessLookup[compositeKey]!; + final rawMasuk = access['data_masuk']; + final formattedMasuk = _formatTime(rawMasuk) ?? '07:00'; + final timeParts = formattedMasuk.split(':'); + if (timeParts.length >= 2) { + final hour = int.tryParse(timeParts[0]) ?? 0; + final minute = int.tryParse(timeParts[1]) ?? 0; + if (hour > 7 || (hour == 7 && minute > 0)) { + updateData['ket_hadir'] = 'Terlambat'; + } else { + updateData['ket_hadir'] = 'Hadir'; + } + } else { + updateData['ket_hadir'] = 'Hadir'; + } + } else { + // Cek apakah tanggal sudah lewat → Alpha + try { + final rowDate = DateTime.parse(row['date'].toString()); + if (rowDate.isBefore(DateTime(now.year, now.month, now.day))) { + updateData['ket_alpha'] = 'Alpha'; + } + } catch (_) {} + } + + // Bandingkan dengan data lama di row. Jika sama persis, tidak perlu update ke database. + if (row['ket_hadir'] == updateData['ket_hadir'] && + row['ket_izin'] == updateData['ket_izin'] && + row['ket_sakit'] == updateData['ket_sakit'] && + row['ket_alpha'] == updateData['ket_alpha']) { + continue; + } + + final futureUpdate = _supabase + .from('absen') + .update(updateData) + .eq('id_tabel', idTabel); + updates.add(futureUpdate); + } + + // Jalankan update secara paralel dalam chunk berukuran 15 + const int chunkSize = 15; + for (int i = 0; i < updates.length; i += chunkSize) { + final end = (i + chunkSize > updates.length) ? updates.length : i + chunkSize; + final chunk = updates.sublist(i, end); + await Future.wait(chunk); + } + } + + // === HELPER: Manual Override via SharedPreferences === + + static const String _manualOverrideKey = 'absen_manual_override_ids'; + + Future> _getManualOverrideIds() async { + try { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(_manualOverrideKey) ?? []; + return list.toSet(); + } catch (_) { + return {}; + } + } + + Future _setManualOverride(String idTabel, bool isManual) async { + try { + final prefs = await SharedPreferences.getInstance(); + final current = (prefs.getStringList(_manualOverrideKey) ?? []).toSet(); + if (isManual) { + current.add(idTabel); + } else { + current.remove(idTabel); + } + await prefs.setStringList(_manualOverrideKey, current.toList()); + } catch (_) {} + } + + /// Ambil data rekap bulanan — query dari tabel absen difilter per murid. + /// Menerima [listIdMurid] agar tidak campur data dengan kelas lain. + Future>> getMonthlyRekapData( + String yearMonth, + List listIdMurid, + ) async { + if (listIdMurid.isEmpty) return []; + + final rekapKey = 'REKAP_$yearMonth'; + + final response = await _supabase + .from('absen') + .select('*, murid:id_murid(*)') + .eq('absensi_baru', rekapKey) + .inFilter('id_murid', listIdMurid) + .order('date', ascending: true); + + return List>.from(response); + } + + // === CUSTOM SESSIONS METHODS === + + Future createNewSession(String judul, DateTime date) async { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + if (userId == null) { + throw Exception('Sesi tidak valid.'); + } + + // Check if session already exists + final formattedDate = DateFormat('yyyy-MM-dd').format(date); + final existing = await _supabase + .from('absen') + .select('id_tabel') + .eq('user_id', userId) + .eq('absensi_baru', judul) + .eq('date', formattedDate) + .limit(1) + .maybeSingle(); + + if (existing != null) { + throw Exception('Sesi absensi dengan judul dan tanggal tersebut sudah ada.'); + } + + // Fetch murid list + final dbKelas = DbKelas(); + final students = await dbKelas.getMuridForGuru(); + if (students.isEmpty) { + throw Exception('Tidak ada murid di kelas Anda.'); + } + + // Prepare insert rows, skip murid jika belum waktunya (H+1) + final List> rowsToInsert = []; + for (var murid in students) { + final String? createdAtStr = murid['created_at']?.toString(); + if (createdAtStr != null) { + final parsed = DateTime.tryParse(createdAtStr); + if (parsed != null) { + final joinDate = parsed.toLocal(); + final joinDateOnly = DateTime(joinDate.year, joinDate.month, joinDate.day); + final sessionDateOnly = DateTime(date.year, date.month, date.day); + // Skip if session date is before or equal to join date (H+1 logic) + if (sessionDateOnly.isBefore(joinDateOnly) || sessionDateOnly.isAtSameMomentAs(joinDateOnly)) { + continue; + } + } + } + rowsToInsert.add({ + 'date': formattedDate, + 'user_id': userId, + 'id_murid': murid['id_tabel'], + 'absensi_baru': judul, + }); + } + + // Insert into absen table + await _supabase.from('absen').insert(rowsToInsert); + } + + Future>> getCustomSessions() async { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + if (userId == null) { + throw Exception('Sesi tidak valid.'); + } + + final response = await _supabase + .from('absen') + .select('absensi_baru, date') + .eq('user_id', userId) + .not('absensi_baru', 'is', null) + .not('absensi_baru', 'like', 'REKAP_%'); + + final List> sessions = []; + final Set uniqueKeys = {}; + for (var item in response) { + final judul = item['absensi_baru'] as String?; + final date = item['date'] as String?; + if (judul == null || date == null) continue; + final key = '$judul|$date'; + if (!uniqueKeys.contains(key)) { + uniqueKeys.add(key); + sessions.add({ + 'judul': judul, + 'date': date, + }); + } + } + + // sort by date descending + sessions.sort((a, b) => b['date'].toString().compareTo(a['date'].toString())); + return sessions; + } + + Future>> getSessionStudents(String judul, String date) async { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + if (userId == null) { + throw Exception('Sesi tidak valid.'); + } + + final response = await _supabase + .from('absen') + .select('*, murid:id_murid(*)') + .eq('user_id', userId) + .eq('absensi_baru', judul) + .eq('date', date); + + final List> list = List>.from(response); + list.sort((a, b) { + final nameA = a['murid']?['nama']?.toString() ?? ''; + final nameB = b['murid']?['nama']?.toString() ?? ''; + return nameA.compareTo(nameB); + }); + return list; + } + + Future updateStudentSessionStatus({ + required String idTabel, + required String? selectedStatus, // 'hadir', 'izin', 'sakit', 'alpha', 'libur', or null + }) async { + try { + // 1. Ambil data murid, tanggal, dan user_id dari baris absen + final response = await _supabase + .from('absen') + .select('id_murid, date, user_id') + .eq('id_tabel', idTabel) + .maybeSingle(); + + if (response != null) { + final idMurid = response['id_murid']?.toString(); + final dateStr = response['date']?.toString(); + var userId = response['user_id']?.toString(); + + if (idMurid != null && dateStr != null) { + final startOfDay = '${dateStr}T00:00:00'; + final endOfDay = '${dateStr}T23:59:59'; + + if (selectedStatus == 'hadir') { + // Cek apakah data access_bridge sudah ada untuk hari ini + final existingAccess = await _supabase + .from('access_bridge') + .select('id_tabel') + .eq('id_murid', idMurid) + .gte('created_at', startOfDay) + .lte('created_at', endOfDay); + + if (existingAccess.isEmpty) { + if (userId == null || userId.isEmpty) { + userId = _supabase.auth.currentUser?.id ?? ''; + } + await _supabase.from('access_bridge').insert({ + 'id_murid': idMurid, + 'user_id': userId, + 'data_masuk': '${dateStr}T07:00:00', + 'created_at': '${dateStr}T07:00:00', + }); + } + } else { + // Jika status manual diubah selain Hadir (seperti Sakit/Izin/Alpha/Libur/Belum Absen), + // hapus record access_bridge hari ini agar tidak bentrok + await _supabase + .from('access_bridge') + .delete() + .eq('id_murid', idMurid) + .gte('created_at', startOfDay) + .lte('created_at', endOfDay); + } + } + } + } catch (_) {} + + final Map updateData = { + 'ket_hadir': selectedStatus == 'hadir' ? 'MANUAL_Hadir' : null, + 'ket_izin': selectedStatus == 'izin' ? 'MANUAL_Izin' : null, + 'ket_sakit': selectedStatus == 'sakit' ? 'MANUAL_Sakit' : null, + 'ket_alpha': selectedStatus == 'alpha' ? 'MANUAL_Alpha' : null, + 'ket_libur': selectedStatus == 'libur' ? 'MANUAL_Libur' : null, + 'Null_data': (selectedStatus == null || selectedStatus == 'belum_absen') ? 'Belum Absen' : null, + }; + + await _supabase + .from('absen') + .update(updateData) + .eq('id_tabel', idTabel); + + // Tandai sebagai manual override di SharedPreferences. + // selectedStatus == null (belum absen) → hapus flag agar auto-sync bisa berjalan lagi. + await _setManualOverride(idTabel, selectedStatus != null); + } + + + Future deleteSession(String judul, String date) async { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + if (userId == null) { + throw Exception('Sesi tidak valid.'); + } + + await _supabase + .from('absen') + .delete() + .eq('user_id', userId) + .eq('absensi_baru', judul) + .eq('date', date); + } +} + diff --git a/lib/dashboard/d_guru/database/db_grup_kelas.dart b/lib/dashboard/d_guru/database/db_grup_kelas.dart new file mode 100644 index 0000000..45ace72 --- /dev/null +++ b/lib/dashboard/d_guru/database/db_grup_kelas.dart @@ -0,0 +1,172 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbGrupKelas { + static final supabase = Supabase.instance.client; + + static Future getUserIdAsync() async { + final prefs = await SharedPreferences.getInstance(); + final ortuId = prefs.getString('user_id_admin'); + if (ortuId != null) return ortuId; + + final id = supabase.auth.currentUser?.id; + if (id != null) return id; + + throw Exception('User belum login'); + } + + // Determine current user's role and corresponding table ID + static Future?> getCurrentUserRoleAndId() async { + try { + final prefs = await SharedPreferences.getInstance(); + final role = prefs.getString('user_role'); + + // 1. Cek Ortu/Murid (menggunakan SharedPreferences) + if (role == 'ortu' || role == 'murid') { + final nis = prefs.getString('murid_nis'); + final adminId = prefs.getString('user_id_admin'); + if (nis != null && adminId != null) { + final muridRes = await supabase + .from('murid') + .select('id_tabel, id_class') + .eq('user_id', adminId) + .eq('nis', nis) + .or('status_akun.is.null,status_akun.eq.true') + .maybeSingle(); + if (muridRes != null) { + return {'role': 'murid', 'id': muridRes['id_tabel'].toString(), 'id_class': muridRes['id_class']?.toString() ?? ''}; + } + } + return null; + } + + // 2. Cek Guru (menggunakan SharedPreferences) + if (role == 'guru') { + final nik = prefs.getString('guru_nik'); + final adminId = prefs.getString('user_id_admin'); + if (nik != null && adminId != null) { + final guruRes = await supabase + .from('guru') + .select('id_tabel, id_class, status_akun') + .eq('user_id', adminId) + .eq('nik', nik) + .maybeSingle(); + if (guruRes != null && guruRes['status_akun'] != false) { + String classId = guruRes['id_class']?.toString() ?? ''; + if (classId.isEmpty) { + // Fallback: look up class from class_name table where this teacher is Wali Kelas + final classRes = await supabase + .from('class_name') + .select('id_tabel') + .eq('id_guru', guruRes['id_tabel']) + .maybeSingle(); + if (classRes != null) { + classId = classRes['id_tabel']?.toString() ?? ''; + } + } + return {'role': 'guru', 'id': guruRes['id_tabel'].toString(), 'id_class': classId}; + } + } + return null; + } + + // 3. Cek Admin (menggunakan Supabase Auth) + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + final adminRes = await supabase + .from('user_admin') + .select('id') + .eq('id', userId) + .maybeSingle(); + if (adminRes != null) { + return {'role': 'admin', 'id': adminRes['id'].toString()}; + } + + } catch (e) { + if (kDebugMode) print('Check Role Error: $e'); + } + return null; + } + + // Ambil semua nama (Admin, Guru, Murid) untuk ditampilkan di chat + static Future> fetchAllNames() async { + final Map namesMap = {}; + try { + // Fetch Admin names + final admins = await supabase.from('user_admin').select('id, name'); + for (var a in admins) { + if (a['id'] != null && a['name'] != null) { + namesMap[a['id'].toString()] = a['name'].toString(); + } + } + + // Fetch Guru names + final gurus = await supabase + .from('guru') + .select('id_tabel, name') + .or('status_akun.is.null,status_akun.eq.true'); + for (var g in gurus) { + if (g['id_tabel'] != null && g['name'] != null) { + namesMap[g['id_tabel'].toString()] = g['name'].toString(); + } + } + + // Fetch Murid names + // Fetch Murid names + final murids = await supabase + .from('murid') + .select('id_tabel, nama') + .or('status_akun.is.null,status_akun.eq.true'); + for (var m in murids) { + if (m['id_tabel'] != null && m['nama'] != null) { + namesMap[m['id_tabel'].toString()] = m['nama'].toString(); + } + } + } catch (e) { + if (kDebugMode) print('Fetch Names Error: $e'); + } + return namesMap; + } + + Stream>> getGrupKelasStream(String idClass) { + return supabase + .from('grup_kelas') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((msg) => msg['id_class']?.toString() == idClass) + .toList()); + } + + // Kirim Pesan + Future sendMessage({ + required String text, + required String senderId, + required String role, + required String idClass, + }) async { + try { + final userId = await getUserIdAsync(); + final Map insertData = { + 'text': text, + 'user_id': userId, + 'id_class': idClass, + }; + + if (role == 'admin') { + insertData['pengirim_admin'] = senderId; + } else if (role == 'guru') { + insertData['pengirim_guru'] = senderId; + } else if (role == 'murid') { + insertData['pengirim_murid'] = senderId; + } + + await supabase.from('grup_kelas').insert(insertData); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } +} diff --git a/lib/dashboard/d_guru/database/db_grup_sekolah.dart b/lib/dashboard/d_guru/database/db_grup_sekolah.dart new file mode 100644 index 0000000..48236f9 --- /dev/null +++ b/lib/dashboard/d_guru/database/db_grup_sekolah.dart @@ -0,0 +1,155 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbGrupSekolah { + static final supabase = Supabase.instance.client; + + static Future getUserIdAsync() async { + final prefs = await SharedPreferences.getInstance(); + final ortuId = prefs.getString('user_id_admin'); + if (ortuId != null) return ortuId; + + final id = supabase.auth.currentUser?.id; + if (id != null) return id; + + throw Exception('User belum login'); + } + + // Determine current user's role and corresponding table ID + static Future?> getCurrentUserRoleAndId() async { + try { + final prefs = await SharedPreferences.getInstance(); + final role = prefs.getString('user_role'); + + // 1. Cek Ortu/Murid (menggunakan SharedPreferences) + if (role == 'ortu' || role == 'murid') { + final nis = prefs.getString('murid_nis'); + final adminId = prefs.getString('user_id_admin'); + if (nis != null && adminId != null) { + final muridRes = await supabase + .from('murid') + .select('id_tabel') + .eq('user_id', adminId) + .eq('nis', nis) + .or('status_akun.is.null,status_akun.eq.true') + .maybeSingle(); + if (muridRes != null) { + return {'role': 'murid', 'id': muridRes['id_tabel'].toString()}; + } + } + return null; + } + + // 2. Cek Guru (menggunakan SharedPreferences) + if (role == 'guru') { + final nik = prefs.getString('guru_nik'); + final adminId = prefs.getString('user_id_admin'); + if (nik != null && adminId != null) { + final guruRes = await supabase + .from('guru') + .select('id_tabel, status_akun') + .eq('user_id', adminId) + .eq('nik', nik) + .maybeSingle(); + if (guruRes != null && guruRes['status_akun'] != false) { + return {'role': 'guru', 'id': guruRes['id_tabel'].toString()}; + } + } + return null; + } + + // 3. Cek Admin (menggunakan Supabase Auth) + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + final adminRes = await supabase + .from('user_admin') + .select('id') + .eq('id', userId) + .maybeSingle(); + if (adminRes != null) { + return {'role': 'admin', 'id': adminRes['id'].toString()}; + } + + } catch (e) { + if (kDebugMode) print('Check Role Error: $e'); + } + return null; + } + + // Ambil semua nama (Admin, Guru, Murid) untuk ditampilkan di chat + static Future> fetchAllNames() async { + final Map namesMap = {}; + try { + // Fetch Admin names + final admins = await supabase.from('user_admin').select('id, name'); + for (var a in admins) { + if (a['id'] != null && a['name'] != null) { + namesMap[a['id'].toString()] = a['name'].toString(); + } + } + + // Fetch Guru names + final gurus = await supabase + .from('guru') + .select('id_tabel, name') + .or('status_akun.is.null,status_akun.eq.true'); + for (var g in gurus) { + if (g['id_tabel'] != null && g['name'] != null) { + namesMap[g['id_tabel'].toString()] = g['name'].toString(); + } + } + + // Fetch Murid names + final murids = await supabase + .from('murid') + .select('id_tabel, nama') + .or('status_akun.is.null,status_akun.eq.true'); + for (var m in murids) { + if (m['id_tabel'] != null && m['nama'] != null) { + namesMap[m['id_tabel'].toString()] = m['nama'].toString(); + } + } + } catch (e) { + if (kDebugMode) print('Fetch Names Error: $e'); + } + return namesMap; + } + + // Stream chat global + Stream>> getGrupSekolahStream() { + return supabase + .from('grup_sekolah') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true); + } + + // Kirim Pesan + Future sendMessage({ + required String text, + required String senderId, + required String role, + }) async { + try { + final userId = await getUserIdAsync(); + final Map insertData = { + 'text': text, + 'user_id': userId, + }; + + if (role == 'admin') { + insertData['pengirim_admin'] = senderId; + } else if (role == 'guru') { + insertData['pengirim_guru'] = senderId; + } else if (role == 'murid') { + insertData['pengirim_murid'] = senderId; + } + + await supabase.from('grup_sekolah').insert(insertData); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } +} diff --git a/lib/dashboard/d_guru/database/db_guru.dart b/lib/dashboard/d_guru/database/db_guru.dart new file mode 100644 index 0000000..6b5bd36 --- /dev/null +++ b/lib/dashboard/d_guru/database/db_guru.dart @@ -0,0 +1,199 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbGuru { + final SupabaseClient _supabase = Supabase.instance.client; + + Future>> getAllGuru() async { + try { + // Fetch all guru, possibly with their class name if you have a relationship set up + // Or just fetch the table and handle join locally if needed + final response = await _supabase + .from('guru') + .select('*, class_name!guru_id_class_fkey(*)') + .or('status_akun.is.null,status_akun.eq.true'); + + return List>.from(response); + } catch (e) { + // If there's no relationship 'class_name', fallback to normal select + try { + final fallbackResponse = await _supabase + .from('guru') + .select('*') + .or('status_akun.is.null,status_akun.eq.true'); + return List>.from(fallbackResponse); + } catch (e2) { + throw Exception('Gagal memuat data guru: $e2'); + } + } + } + + Future>> getGuruForStudentClass() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nis = prefs.getString('murid_nis'); + + if (userId == null || nis == null) return []; + + // 1. Dapatkan info murid untuk mengambil id_class murid tersebut + final muridData = await _supabase + .from('murid') + .select('id_class') + .eq('user_id', userId) + .eq('nis', nis) + .maybeSingle(); + + if (muridData == null || muridData['id_class'] == null) return []; + final String studentClassId = muridData['id_class'].toString(); + + // 2. Dapatkan Wali Kelas untuk kelas tersebut + final classData = await _supabase + .from('class_name') + .select('id_guru') + .eq('id_tabel', studentClassId) + .maybeSingle(); + + final String? waliKelasGuruId = classData?['id_guru']?.toString(); + + // 3. Dapatkan guru yang mengajar di kelas murid tersebut (guru.id_class == studentClassId) + // dan juga Wali Kelas dari kelas tersebut (guru.id_tabel == waliKelasGuruId) + var query = _supabase + .from('guru') + .select('*') + .or('status_akun.is.null,status_akun.eq.true'); + + if (waliKelasGuruId != null && waliKelasGuruId.isNotEmpty) { + query = query.or('id_class.eq.$studentClassId,id_tabel.eq.$waliKelasGuruId'); + } else { + query = query.eq('id_class', studentClassId); + } + + final response = await query; + return List>.from(response); + } catch (e) { + return []; + } + } + + Future>> getGuruForLoggedGuruClass() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nik = prefs.getString('guru_nik'); + + if (userId == null || nik == null) return []; + + // 1. Dapatkan info guru yang login + final guruData = await _supabase + .from('guru') + .select('id_tabel, id_class') + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + + if (guruData == null) return []; + + Set validClassIds = {}; + if (guruData['id_class'] != null) { + validClassIds.add(guruData['id_class'].toString()); + } + + // Ambil class_name yang terkait dengan guru ini + final classDataList = await _supabase + .from('class_name') + .select('id_tabel, id_class') + .eq('id_guru', guruData['id_tabel']); + + for (var c in classDataList) { + validClassIds.add(c['id_tabel'].toString()); + } + + if (validClassIds.isEmpty) return []; + + // 2. Ambil semua guru yang terhubung ke kelas yang sama (melalui id_class di tabel guru) + final gurusInClass = await _supabase + .from('guru') + .select('*, class_name!guru_id_class_fkey(*)') + .inFilter('id_class', validClassIds.toList()) + .or('status_akun.is.null,status_akun.eq.true'); + + return List>.from(gurusInClass); + } catch (e) { + throw Exception('Gagal memuat data guru per kelas: $e'); + } + } + Future> getLoggedInGuruInfo() async { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nik = prefs.getString('guru_nik'); + + if (userId == null || nik == null) { + throw Exception('Sesi guru tidak valid.'); + } + + final guruData = await _supabase + .from('guru') + .select() + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + + if (guruData == null) { + throw Exception('Data guru tidak ditemukan.'); + } + + // Cek apakah wali kelas + final classData = await _supabase + .from('class_name') + .select('name_class') + .eq('id_guru', guruData['id_tabel']) + .maybeSingle(); + + guruData['is_wali_kelas'] = classData != null; + + if (classData != null) { + guruData['class_name'] = classData['name_class']; + } else if (guruData['id_class'] != null) { + // Jika bukan wali kelas, ambil nama kelas berdasarkan id_class + final classDataById = await _supabase + .from('class_name') + .select('name_class') + .eq('id_tabel', guruData['id_class']) + .maybeSingle(); + guruData['class_name'] = classDataById?['name_class']; + } else { + guruData['class_name'] = null; + } + + return guruData; + } + + Future updateGuru({ + required String idTabel, + required num nik, + required String name, + String? bidang, + String? idClass, + required bool wali, + }) async { + await _supabase.from('guru').update({ + 'nik': nik, + 'name': name, + 'bidang': bidang, + 'id_class': idClass, + 'wali': wali, + }).eq('id_tabel', idTabel); + } + + Future deleteGuru(String idTabel) async { + try { + await _supabase.from('guru').update({ + 'status_akun': false, + 'id_class': null, + }).eq('id_tabel', idTabel); + } catch (e) { + throw Exception('Gagal menghapus guru: $e'); + } + } +} diff --git a/lib/dashboard/d_guru/database/db_izin.dart b/lib/dashboard/d_guru/database/db_izin.dart new file mode 100644 index 0000000..10075e7 --- /dev/null +++ b/lib/dashboard/d_guru/database/db_izin.dart @@ -0,0 +1,28 @@ +import 'dart:typed_data'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DbIzin { + final SupabaseClient _supabase = Supabase.instance.client; + + Future uploadIzinFile(String authId, String nis, Uint8List fileBytes, String originalFileName) async { + try { + final fileName = '${DateTime.now().millisecondsSinceEpoch}_$originalFileName'; + final filePath = '$authId/$nis/$fileName'; + + await _supabase.storage.from('doc_izin').uploadBinary(filePath, fileBytes); + return _supabase.storage.from('doc_izin').getPublicUrl(filePath); + + } catch (e) { + throw Exception('Gagal mengunggah surat izin: $e'); + } + } + + Future> submitIzin(Map data) async { + try { + final res = await _supabase.from('leave_request').insert(data).select().single(); + return res; + } catch (e) { + throw Exception('Gagal mengajukan izin: $e'); + } + } +} diff --git a/lib/dashboard/d_guru/database/db_kelas.dart b/lib/dashboard/d_guru/database/db_kelas.dart new file mode 100644 index 0000000..fb117a8 --- /dev/null +++ b/lib/dashboard/d_guru/database/db_kelas.dart @@ -0,0 +1,136 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbKelas { + final _supabase = Supabase.instance.client; + + Future>> getMuridForGuru() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nik = prefs.getString('guru_nik'); + + if (userId == null || nik == null) { + throw Exception('Sesi guru tidak valid.'); + } + + // 1. Dapatkan Guru dan id_class-nya jika ada + final guruData = await _supabase + .from('guru') + .select('id_tabel, id_class') + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + + if (guruData == null) { + throw Exception('Data guru tidak ditemukan.'); + } + + final guruIdTabel = guruData['id_tabel']; + final guruIdClass = guruData['id_class']; // Dari tabel guru + + // 2. Kumpulkan semua ID Kelas (UUID) yang terkait dengan guru ini + Set validClassIds = {}; + if (guruIdClass != null) { + validClassIds.add(guruIdClass.toString()); + } + + // Juga cek tabel class_name dimana id_guru = guruIdTabel + final classDataList = await _supabase + .from('class_name') + .select('id_tabel, id_class') + .eq('id_guru', guruIdTabel); + + for (var c in classDataList) { + validClassIds.add(c['id_tabel'].toString()); + } + + if (validClassIds.isEmpty) { + // Guru benar-benar tidak terhubung ke kelas manapun + return []; + } + + // 3. Kumpulkan Murid dengan berbagai kemungkinan relasi + Map> allMurid = {}; + + // Relasi A: Murid yang id_class-nya merujuk ke validClassIds + final muridListA = await _supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class, created_at') + .or('status_akun.is.null,status_akun.eq.true') + .inFilter('id_class', validClassIds.toList()); + + for (var m in muridListA) { + allMurid[m['id_tabel'].toString()] = m; + } + + // Relasi B: Membaca dari id_murid di tabel class_name (seperti instruksi Anda) + // Cari baris class_name yang id_tabel-nya ada di validClassIds DAN punya id_murid + final classMuridRows = await _supabase + .from('class_name') + .select('id_murid') + .inFilter('id_tabel', validClassIds.toList()) + .not('id_murid', 'is', null); + + List muridIdsFromClass = classMuridRows + .map((r) => r['id_murid'].toString()) + .where((id) => id != 'null') + .toList(); + + if (muridIdsFromClass.isNotEmpty) { + final muridListB = await _supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class, created_at') + .or('status_akun.is.null,status_akun.eq.true') + .inFilter('id_tabel', muridIdsFromClass); + + for (var m in muridListB) { + allMurid[m['id_tabel'].toString()] = m; + } + } + + // Relasi C: Jika class_name menggunakan 'id_class' numeric untuk mengelompokkan + // Coba cari numeric id_class dari classDataList + List numericClassIds = classDataList + .map((c) => c['id_class'] as num?) + .where((id) => id != null) + .cast() + .toList(); + + if (numericClassIds.isNotEmpty) { + final classRowsByNumeric = await _supabase + .from('class_name') + .select('id_murid') + .inFilter('id_class', numericClassIds) + .not('id_murid', 'is', null); + + List extraMuridIds = classRowsByNumeric + .map((r) => r['id_murid'].toString()) + .where((id) => id != 'null') + .toList(); + + if (extraMuridIds.isNotEmpty) { + final muridListC = await _supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class, created_at') + .or('status_akun.is.null,status_akun.eq.true') + .inFilter('id_tabel', extraMuridIds); + + for (var m in muridListC) { + allMurid[m['id_tabel'].toString()] = m; + } + } + } + + // Return hasil akhir dan urutkan berdasarkan nama + final finalResult = allMurid.values.toList(); + finalResult.sort((a, b) => (a['nama']?.toString() ?? '').compareTo(b['nama']?.toString() ?? '')); + + return finalResult; + + } catch (e) { + print('DEBUG getMuridForGuru ERROR: $e'); + throw Exception('Gagal memuat murid: $e'); + } + } +} diff --git a/lib/dashboard/d_guru/database/db_murid.dart b/lib/dashboard/d_guru/database/db_murid.dart new file mode 100644 index 0000000..b58c1d6 --- /dev/null +++ b/lib/dashboard/d_guru/database/db_murid.dart @@ -0,0 +1,102 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DbMurid { + final SupabaseClient _supabase = Supabase.instance.client; + + Future>> getAllMurid() async { + try { + final response = await _supabase + .from('murid') + .select('*, class_name(*)') + .or('status_akun.is.null,status_akun.eq.true'); + + return List>.from(response); + } catch (e) { + try { + final fallbackResponse = await _supabase + .from('murid') + .select('*') + .or('status_akun.is.null,status_akun.eq.true'); + return List>.from(fallbackResponse); + } catch (e2) { + throw Exception('Gagal memuat data murid: $e2'); + } + } + } + + Future addMurid({ + required String nis, + required String nama, + required String userId, + String? idClass, + String? gender, + DateTime? tanggalLahir, + String? alamat, + String? orangTua, + num? noTele, + }) async { + try { + await _supabase.from('murid').insert({ + 'nis': nis, + 'nama': nama, + 'user_id': userId, + 'id_class': idClass, + 'gender': gender, + 'tanggal_lahir': tanggalLahir?.toIso8601String().split('T').first, + 'alamat': alamat, + 'orang_tua': orangTua, + 'no_tele': noTele, + 'status_akun': true, + }); + } catch (e) { + throw Exception('Gagal menambahkan murid: $e'); + } + } + + Future deleteMurid(String idTabel) async { + try { + await _supabase.from('murid').update({ + 'status_akun': false, + 'id_class': null, + }).eq('id_tabel', idTabel); + } catch (e) { + throw Exception('Gagal menghapus murid: $e'); + } + } + + Future>> getKelas() async { + try { + final response = await _supabase.from('class_name').select('*'); + return List>.from(response); + } catch (e) { + throw Exception('Gagal memuat data kelas: $e'); + } + } + + Future updateMurid({ + required String idTabel, + required String nis, + required String nama, + String? idClass, + String? gender, + DateTime? tanggalLahir, + String? alamat, + String? orangTua, + num? noTele, + }) async { + try { + await _supabase.from('murid').update({ + 'nis': nis, + 'nama': nama, + 'id_class': idClass, + 'gender': gender, + 'tanggal_lahir': tanggalLahir?.toIso8601String().split('T').first, + 'alamat': alamat, + 'orang_tua': orangTua, + 'no_tele': noTele, + }).eq('id_tabel', idTabel); + } catch (e) { + throw Exception('Gagal mengupdate profil: $e'); + } + } +} diff --git a/lib/dashboard/d_guru/database/db_namasekolah.dart b/lib/dashboard/d_guru/database/db_namasekolah.dart new file mode 100644 index 0000000..1abfe4e --- /dev/null +++ b/lib/dashboard/d_guru/database/db_namasekolah.dart @@ -0,0 +1,39 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class NamaSekolahService { + static final supabase = Supabase.instance.client; + + /// Mengambil nama sekolah saja (backward-compat). + static Future getNamaSekolah() async { + final info = await getSekolahInfo(); + return info['sekolah']; + } + + /// Mengambil informasi sekolah lengkap: nama sekolah dan wilayah. + /// Mengembalikan Map dengan key 'sekolah' dan 'wilayah'. + static Future> getSekolahInfo() async { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + if (userId == null) return {'sekolah': null, 'wilayah': null}; + + try { + final response = await supabase + .from('nama_sekolah') + .select('sekolah, wilayah') + .eq('user_id', userId) + .maybeSingle(); + + if (response != null) { + return { + 'sekolah': response['sekolah'] as String?, + 'wilayah': response['wilayah'] as String?, + }; + } + return {'sekolah': null, 'wilayah': null}; + } catch (e) { + print("Error fetching school info: $e"); + return {'sekolah': null, 'wilayah': null}; + } + } +} diff --git a/lib/dashboard/d_guru/database/db_pengumuman.dart b/lib/dashboard/d_guru/database/db_pengumuman.dart new file mode 100644 index 0000000..29ba27b --- /dev/null +++ b/lib/dashboard/d_guru/database/db_pengumuman.dart @@ -0,0 +1,87 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbPengumuman { + static final supabase = Supabase.instance.client; + + static Future getUserIdAsync() async { + final prefs = await SharedPreferences.getInstance(); + final ortuId = prefs.getString('user_id_admin'); + if (ortuId != null) return ortuId; + + final id = supabase.auth.currentUser?.id; + if (id != null) return id; + + throw Exception('User belum login'); + } + + // Insert pengumuman baru + static Future> insertPengumuman({ + required String title, + required String tanggalMulai, + required String tanggalSelesai, + String? keterangan, + String? idGuru, + }) async { + // Bersihkan pengumuman yang sudah kadaluarsa + await deleteExpiredPengumuman(); + + final userId = await getUserIdAsync(); + + final data = { + 'user_id': userId, + 'title': title, + 'tanggal_mulai': tanggalMulai, + 'tanggal_selesai': tanggalSelesai, + if (keterangan != null && keterangan.isNotEmpty) 'keterangan': keterangan, + if (idGuru != null && idGuru.isNotEmpty) 'id_guru': idGuru, + }; + + final res = await supabase.from('pengumuman').insert(data).select().single(); + return res; + } + + // Get pengumuman berdasarkan ID + static Future?> getPengumumanById(String id) async { + try { + final res = await supabase.from('pengumuman').select().eq('id_tabel', id).maybeSingle(); + return res; + } catch (e) { + return null; + } + } + + // Hapus otomatis pengumuman yang sudah berakhir (tanggal_selesai < hari ini) + static Future deleteExpiredPengumuman() async { + try { + final now = DateTime.now(); + final todayStr = "${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}"; + + // 1. Dapatkan semua ID pengumuman yang sudah kadaluarsa + final expired = await supabase + .from('pengumuman') + .select('id_tabel') + .lt('tanggal_selesai', todayStr); + + if (expired != null && expired.isNotEmpty) { + final List expiredIds = expired + .map((e) => e['id_tabel'].toString()) + .toList(); + + // 2. Kosongkan referensi id_pengumuman di tabel absen agar tidak melanggar foreign key constraint + await supabase + .from('absen') + .update({'id_pengumuman': null}) + .inFilter('id_pengumuman', expiredIds); + + // 3. Hapus pengumuman tersebut dari tabel pengumuman + await supabase + .from('pengumuman') + .delete() + .inFilter('id_tabel', expiredIds); + } + } catch (e) { + // Silently ignore or log error + } + } +} diff --git a/lib/dashboard/d_guru/database/db_pesan_private.dart b/lib/dashboard/d_guru/database/db_pesan_private.dart new file mode 100644 index 0000000..8c4d3f2 --- /dev/null +++ b/lib/dashboard/d_guru/database/db_pesan_private.dart @@ -0,0 +1,126 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DbPesanPrivate { + static final supabase = Supabase.instance.client; + + static Future getUserIdAsync() async { + final prefs = await SharedPreferences.getInstance(); + final ortuId = prefs.getString('user_id_admin'); + if (ortuId != null) return ortuId; + + final id = supabase.auth.currentUser?.id; + if (id != null) return id; + + throw Exception('User belum login'); + } + + // Get Admin ID (for the current logged in user) + Future getAdminId() async { + try { + final userId = await getUserIdAsync(); + final res = await supabase + .from('user_admin') + .select('id') + .eq('id', userId) + .single(); + return res['id']?.toString(); + } catch (e) { + if (kDebugMode) print('Get Admin ID Error: $e'); + return null; + } + } + + // Stream chat between Admin and Guru + Stream>> getChatWithGuruStream(String idAdmin, String idGuru) { + return supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_admin'] == idAdmin && e['penerima_guru'] == idGuru) || + (e['pengirim_guru'] == idGuru && e['penerima_admin'] == idAdmin)) + .map((e) => e as Map) + .toList()); + } + + // Stream chat between Admin and Murid + Stream>> getChatWithMuridStream(String idAdmin, String idMurid) { + return supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_admin'] == idAdmin && e['penerima_murid'] == idMurid) || + (e['pengirim_murid'] == idMurid && e['penerima_admin'] == idAdmin)) + .map((e) => e as Map) + .toList()); + } + + // Send Message to Guru + Future sendMessageToGuru({ + required String text, + required String idAdmin, + required String idGuru, + }) async { + try { + final userId = await getUserIdAsync(); + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_admin': idAdmin, + 'penerima_guru': idGuru, + 'user_id': userId, + }); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } + + // Send Message to Murid + Future sendMessageToMurid({ + required String text, + required String idAdmin, + required String idMurid, + }) async { + try { + final userId = await getUserIdAsync(); + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_admin': idAdmin, + 'penerima_murid': idMurid, + 'user_id': userId, + }); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } + + // Stream chat between Admin and Murid (For Murid App) + Stream>> getChatWithAdminStream(String idMurid, String idAdmin) { + return supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_admin'] == idAdmin && e['penerima_murid'] == idMurid) || + (e['pengirim_murid'] == idMurid && e['penerima_admin'] == idAdmin)) + .map((e) => e as Map) + .toList()); + } + + // Send message general (For Murid App sending to admin) + Future sendMessage(Map data) async { + try { + await supabase.from('chat_private').insert(data); + } catch (e) { + if (kDebugMode) print('Send Message Error: $e'); + throw Exception('Gagal mengirim pesan: $e'); + } + } +} diff --git a/lib/dashboard/d_guru/database/holiday_service.dart b/lib/dashboard/d_guru/database/holiday_service.dart new file mode 100644 index 0000000..f089dab --- /dev/null +++ b/lib/dashboard/d_guru/database/holiday_service.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class HolidayService { + static const String _cacheKey = 'holiday_cache_2026'; + + // Set of formatted dates "yyyy-MM-dd" + static Set _holidays = {}; + + // Fallback major 2026 Indonesian holidays + static const List _fallbackHolidays = [ + '2026-01-01', // Tahun Baru Masehi + '2026-01-27', // Isra Mikraj Nabi Muhammad SAW + '2026-02-17', // Tahun Baru Imlek + '2026-03-20', // Hari Suci Nyepi + '2026-04-03', // Wafat Yesus Kristus + '2026-04-05', // Kebangkitan Yesus Kristus + '2026-05-01', // Hari Buruh Internasional + '2026-05-14', // Kenaikan Yesus Kristus + '2026-05-27', // Hari Raya Idul Adha + '2026-06-01', // Hari Lahir Pancasila + '2026-06-16', // Tahun Baru Islam + '2026-08-17', // Hari Kemerdekaan RI + '2026-08-25', // Maulid Nabi Muhammad SAW + '2026-12-25', // Hari Raya Natal + ]; + + /// Initialize the holiday service. Loads cached values if available, + /// otherwise uses fallback values, and tries to fetch the latest values from API. + static Future init() async { + // 1. Try to load from local cache first + try { + final prefs = await SharedPreferences.getInstance(); + final cachedJson = prefs.getString(_cacheKey); + if (cachedJson != null) { + final List decoded = jsonDecode(cachedJson); + _holidays = decoded.cast().toSet(); + } + } catch (_) { + // Ignore cache load error + } + + // If cache is empty, load fallback initially + if (_holidays.isEmpty) { + _holidays = _fallbackHolidays.toSet(); + } + + // 2. Fetch from API with a 5-second timeout + try { + final apiUrl = dotenv.env['HOLIDAY_API_URL']; + if (apiUrl != null && apiUrl.isNotEmpty) { + final response = await http.get(Uri.parse(apiUrl)).timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + final Set fetchedHolidays = {}; + for (var item in data) { + if (item is Map && item.containsKey('date')) { + fetchedHolidays.add(item['date'].toString()); + } + } + if (fetchedHolidays.isNotEmpty) { + _holidays = fetchedHolidays; + // Save to cache + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_cacheKey, jsonEncode(_holidays.toList())); + } + } + } + } catch (_) { + // Failed to fetch or parse, fallback/cache will be used + } + } + + /// Check if a date is a public holiday + static bool isHoliday(DateTime date) { + final dateStr = DateFormat('yyyy-MM-dd').format(date); + return _holidays.contains(dateStr); + } +} diff --git a/lib/dashboard/d_guru/database/storage/doc_pengumuman.dart b/lib/dashboard/d_guru/database/storage/doc_pengumuman.dart new file mode 100644 index 0000000..43030f8 --- /dev/null +++ b/lib/dashboard/d_guru/database/storage/doc_pengumuman.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DocPengumuman { + static final supabase = Supabase.instance.client; + + static Future uploadDoc(Uint8List bytes, String title, String ext) async { + try { + // Membersihkan nama file dari karakter tidak valid + final cleanTitle = title.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_'); + final fileName = '${DateTime.now().millisecondsSinceEpoch}_$cleanTitle.$ext'; + + await supabase.storage.from('pengumuman').uploadBinary(fileName, bytes); + return supabase.storage.from('pengumuman').getPublicUrl(fileName); + } catch (e) { + return null; + } + } +} diff --git a/lib/dashboard/d_guru/database/storage/image_doc.dart b/lib/dashboard/d_guru/database/storage/image_doc.dart new file mode 100644 index 0000000..0704341 --- /dev/null +++ b/lib/dashboard/d_guru/database/storage/image_doc.dart @@ -0,0 +1,56 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ImageDocStorage { + static final supabase = Supabase.instance.client; + + static Future uploadFile(Uint8List bytes, String fileName) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin') ?? supabase.auth.currentUser?.id; + final nip = prefs.getString('guru_nik') ?? 'admin'; + + if (userId == null) return null; + + // path: userid/nip/doc_image/fileName (di dalam bucket chat_file) + final path = '$userId/$nip/doc_image/${DateTime.now().millisecondsSinceEpoch}_$fileName'; + + await supabase.storage.from('chat_file').uploadBinary( + path, + bytes, + fileOptions: const FileOptions(upsert: true), + ); + + return supabase.storage.from('chat_file').getPublicUrl(path); + } catch (e) { + print('Upload Image/Doc Error: $e'); + return null; + } + } + + static Future uploadFileObject(File file, String fileName) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin') ?? supabase.auth.currentUser?.id; + final nip = prefs.getString('guru_nik') ?? 'admin'; + + if (userId == null) return null; + + // path: userid/nip/doc_image/fileName + final path = '$userId/$nip/doc_image/${DateTime.now().millisecondsSinceEpoch}_$fileName'; + + await supabase.storage.from('chat_file').upload( + path, + file, + fileOptions: const FileOptions(upsert: true), + ); + + return supabase.storage.from('chat_file').getPublicUrl(path); + } catch (e) { + print('Upload File Error: $e'); + return null; + } + } +} diff --git a/lib/dashboard/d_guru/function/absen_excel/daftar_absen.dart b/lib/dashboard/d_guru/function/absen_excel/daftar_absen.dart new file mode 100644 index 0000000..b1f52d4 --- /dev/null +++ b/lib/dashboard/d_guru/function/absen_excel/daftar_absen.dart @@ -0,0 +1,928 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_guru/function/absen_excel/download.dart'; +import 'package:apk/dashboard/d_guru/database/db_absensi.dart'; +import 'package:apk/dashboard/d_guru/database/db_namasekolah.dart'; +import 'package:apk/dashboard/d_guru/function/absen_excel/pdf/pdf_export_service.dart'; +import 'package:apk/dashboard/d_guru/function/absen_excel/pdf/export_pdf_button.dart'; +import 'package:apk/dashboard/d_guru/database/holiday_service.dart'; +import 'package:apk/dashboard/d_guru/function/absen_excel/setting_L/pengaturan_absen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +class DaftarAbsenExcelPage extends StatefulWidget { + final String monthName; + final String yearMonth; + final List> muridList; + final String namaKelas; + final String namaWaliKelas; + final String nipWaliKelas; + final String wilayah; + + const DaftarAbsenExcelPage({ + super.key, + required this.monthName, + required this.yearMonth, + required this.muridList, + this.namaKelas = '-', + this.namaWaliKelas = '-', + this.nipWaliKelas = '-', + this.wilayah = '', + }); + + @override + State createState() => _DaftarAbsenExcelPageState(); +} + +class _DaftarAbsenExcelPageState extends State { + final DbAbsensi _dbAbsensi = DbAbsensi(); + bool _isLoading = true; + String? _errorMessage; + String _namaSekolah = ''; + + // ScrollControllers untuk Scrollbar (wajib saat thumbVisibility: true) + final ScrollController _verticalScrollController = ScrollController(); + final ScrollController _horizontalScrollController = ScrollController(); + + // Struktur data: attendanceMap[nisStr][day] = status + final Map> _attendanceMap = {}; + // Map composite key "${idMurid}_$day" -> idTabel of the record + final Map _absenIdMap = {}; + int _daysInMonth = 31; + + String get _cacheKey => 'cache_absen_${widget.yearMonth}_${widget.namaKelas}'; + + Future _loadFromCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + final cachedDataStr = prefs.getString(_cacheKey); + if (cachedDataStr != null) { + final Map cachedJson = jsonDecode(cachedDataStr); + + // Restore attendance map + final Map rawAttMap = cachedJson['attendanceMap'] ?? {}; + _attendanceMap.clear(); + rawAttMap.forEach((nis, daysObj) { + final Map dayMap = {}; + if (daysObj is Map) { + daysObj.forEach((dayStr, status) { + final dayInt = int.tryParse(dayStr); + if (dayInt != null) { + dayMap[dayInt] = status.toString(); + } + }); + } + _attendanceMap[nis] = dayMap; + }); + + // Restore absen ID map + final Map rawIdMap = cachedJson['absenIdMap'] ?? {}; + _absenIdMap.clear(); + rawIdMap.forEach((key, val) { + _absenIdMap[key] = val.toString(); + }); + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } catch (_) {} + } + + Future _saveToCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + + // Convert attendance map day keys from int to String for JSON encoding + final Map> jsonAttMap = {}; + _attendanceMap.forEach((nis, dayMap) { + final Map strDayMap = {}; + dayMap.forEach((day, status) { + strDayMap[day.toString()] = status; + }); + jsonAttMap[nis] = strDayMap; + }); + + final Map cacheData = { + 'attendanceMap': jsonAttMap, + 'absenIdMap': _absenIdMap, + }; + + await prefs.setString(_cacheKey, jsonEncode(cacheData)); + } catch (_) {} + } + + @override + void initState() { + super.initState(); + _initData(); + } + + @override + void dispose() { + _verticalScrollController.dispose(); + _horizontalScrollController.dispose(); + super.dispose(); + } + + Future _initData() async { + setState(() => _isLoading = _attendanceMap.isEmpty); + await _loadFromCache(); + _syncInBackground(); + } + + Future _syncInBackground() async { + try { + // Ambil nama sekolah + final sekolah = await NamaSekolahService.getNamaSekolah(); + _namaSekolah = sekolah ?? ''; + + int year = DateTime.now().year; + int month = DateTime.now().month; + // Hitung jumlah hari dalam bulan ini + final parts = widget.yearMonth.split('-'); + if (parts.length == 2) { + year = int.parse(parts[0]); + month = int.parse(parts[1]); + _daysInMonth = DateTime(year, month + 1, 0).day; + } + + // Ambil daftar NIS (sebagai String agar tidak crash jika DB mengembalikan String) + final List listNis = widget.muridList + .map((m) => m['nis']?.toString()) + .where((nis) => nis != null && nis.isNotEmpty) + .cast() + .toList(); + + if (listNis.isEmpty) { + if (mounted) { + setState(() => _isLoading = false); + } + return; + } + + // Generate rekap bulan ini jika belum ada (hanya insert baris kosong, tidak overwrite) + // Kirim muridList langsung agar filter per kelas benar + await _dbAbsensi.generateMonthlyRekap(widget.yearMonth, widget.muridList); + + // Kumpulkan id_murid untuk sync kelas ini + final List listIdMuridStr = widget.muridList + .map((mu) => mu['id_tabel']?.toString()) + .where((id) => id != null) + .cast() + .toList(); + + // Sync otomatis saat pertama buka: data dari output_alat/access_bridge langsung masuk ke absen + // (user tidak perlu klik Refresh manual) + if (listIdMuridStr.isNotEmpty) { + await _dbAbsensi.syncMonthlyRekap(widget.yearMonth, listIdMuridStr); + } + + // Tampilkan data rekap + await _loadData(year: year, month: month); + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Gagal memuat rekap tabel: $e'; + _isLoading = false; + }); + } + } + } + + + /// Hanya fetch + tampilkan data dari DB tanpa menjalankan sync. + /// Dipanggil setelah manual update agar perubahan manual tidak tertimpa sync. + Future _loadData({int? year, int? month}) async { + try { + // Tentukan tahun & bulan jika belum ada + int y = year ?? DateTime.now().year; + int m = month ?? DateTime.now().month; + final parts = widget.yearMonth.split('-'); + if (parts.length == 2) { + y = int.parse(parts[0]); + m = int.parse(parts[1]); + _daysInMonth = DateTime(y, m + 1, 0).day; + } + + final List nisList = widget.muridList + .map((mu) => mu['nis']?.toString()) + .where((nis) => nis != null && nis.isNotEmpty) + .cast() + .toList(); + + _absenIdMap.clear(); + + // Inisialisasi map default (key = nis sebagai String) + for (var nisStr in nisList) { + _attendanceMap[nisStr] = {}; + for (int i = 1; i <= _daysInMonth; i++) { + final date = DateTime(y, m, i); + if (date.weekday == DateTime.sunday || HolidayService.isHoliday(date)) { + _attendanceMap[nisStr]![i] = 'L'; + } else { + _attendanceMap[nisStr]![i] = '-'; + } + } + } + + // Kumpulkan id_murid dari muridList untuk query rekap + final List listIdMuridStr = widget.muridList + .map((mu) => mu['id_tabel']?.toString()) + .where((id) => id != null) + .cast() + .toList(); + + final rekapRecords = await _dbAbsensi.getMonthlyRekapData( + widget.yearMonth, listIdMuridStr); + + // Build lookup id_murid -> nis (String) + final Map idMuridToNis = {}; + for (var mu in widget.muridList) { + final id = mu['id_tabel']?.toString(); + final nisStr = mu['nis']?.toString(); + if (id != null && nisStr != null && nisStr.isNotEmpty) { + idMuridToNis[id] = nisStr; + } + } + + // Mapping data rekap ke _attendanceMap + for (var record in rekapRecords) { + final idMurid = record['id_murid']?.toString(); + if (idMurid == null) continue; + final nisStr = idMuridToNis[idMurid]; + if (nisStr == null) continue; + final dateStr = record['date']?.toString(); + if (dateStr == null) continue; + + try { + final date = DateTime.parse(dateStr); + if (date.year != y || date.month != m) continue; + final day = date.day; + + final idTabel = record['id_tabel']?.toString(); + if (idTabel != null) { + _absenIdMap['${idMurid}_$day'] = idTabel; + } + + if (_attendanceMap.containsKey(nisStr) && day >= 1 && day <= _daysInMonth) { + String shortStatus = '-'; + if (record['Null_data']?.toString() == 'Belum Absen') { + shortStatus = '-'; + } else if (record['ket_libur'] != null && record['ket_libur'].toString().isNotEmpty) { + shortStatus = 'L'; + } else if (record['ket_sakit'] != null && record['ket_sakit'].toString().isNotEmpty) { + shortStatus = 'S'; + } else if (record['ket_izin'] != null && record['ket_izin'].toString().isNotEmpty) { + shortStatus = 'I'; + } else if (record['ket_alpha'] != null && record['ket_alpha'].toString().isNotEmpty) { + shortStatus = 'A'; + } else if (record['ket_hadir'] != null && record['ket_hadir'].toString().isNotEmpty) { + shortStatus = 'H'; // Terlambat tetap dihitung sebagai Hadir + } + _attendanceMap[nisStr]![day] = shortStatus; + } + } catch (_) {} + } + + await _saveToCache(); + + if (mounted) { + setState(() => _isLoading = false); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Gagal memuat rekap tabel: $e'; + _isLoading = false; + }); + } + } + } + + Future _syncAndReload() async { + setState(() => _isLoading = true); + try { + // Kumpulkan id_murid untuk sync hanya kelas ini + final List listIdMuridStr = widget.muridList + .map((mu) => mu['id_tabel']?.toString()) + .where((id) => id != null) + .cast() + .toList(); + await _dbAbsensi.syncMonthlyRekap(widget.yearMonth, listIdMuridStr); + await _loadData(); + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Gagal sync data: $e'; + _isLoading = false; + }); + } + } + } + + // Menghitung rekap per siswa + int _countStatus(String nisStr, String shortStatus) { + if (!_attendanceMap.containsKey(nisStr)) return 0; + int count = 0; + for (int i = 1; i <= _daysInMonth; i++) { + final status = _attendanceMap[nisStr]![i] ?? '-'; + if (status.startsWith(shortStatus)) count++; + } + return count; + } + + // Mewarnai sel berdasarkan status + Color _getCellColor(String status) { + if (status.startsWith('H')) return Colors.green.withOpacity(0.2); + if (status.startsWith('S')) return Colors.blue.withOpacity(0.2); + if (status.startsWith('I')) return Colors.orange.withOpacity(0.2); + if (status.startsWith('A')) return Colors.red.withOpacity(0.2); + if (status.startsWith('T')) return Colors.purple.withOpacity(0.2); + if (status.startsWith('L')) return Colors.red.withOpacity(0.15); + return Colors.transparent; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + titleSpacing: 0, + title: Text('Rekap ${widget.monthName}'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + actions: [ + IconButton( + icon: const Icon(Icons.edit, color: Colors.white), + tooltip: 'Pembaruan Massal', + onPressed: () { + final allStudents = widget.muridList + .map((m) => m['id_tabel']?.toString()) + .whereType() + .toList(); + final allDays = List.generate(_daysInMonth, (i) => i + 1); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => UpdateAbsenBottomSheet( + muridList: widget.muridList, + daysInMonth: _daysInMonth, + yearMonth: widget.yearMonth, + absenIdMap: _absenIdMap, + initialSelectedStudentIds: allStudents, + initialSelectedDays: allDays, + onUpdated: _loadData, + ), + ); + }, + ), + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + tooltip: 'Sync Data', + onPressed: _isLoading ? null : _syncAndReload, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontFamily: 'Inter')), + ), + ) + : Column( + children: [ + // Kop Judul di dalam halaman (seperti foto Excel) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.black12, width: 1)), + ), + child: Column( + children: [ + if (_namaSekolah.isNotEmpty) ...[ + Text( + _namaSekolah.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 4), + ], + const Text( + 'ABSENSI SISWA', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 8), + Text( + 'BULAN : ${widget.monthName.toUpperCase()}', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 1.0, + ), + ), + ], + ), + ), + + // Info Kelas & Jumlah Murid (kiri atas tabel) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow('Kelas', widget.namaKelas), + const SizedBox(height: 4), + _buildInfoRow('Jumlah Murid', '${widget.muridList.length} Siswa'), + ], + ), + ), + ), + + // Tombol Download Excel & PDF + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _downloadExcel, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), // Hijau untuk Excel + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon(Icons.grid_on, color: Colors.white, size: 18), + label: const Text( + 'Download Excel', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + fontFamily: 'Inter', + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ExportPdfButton( + onExport: _downloadPdf, + style: ExportButtonStyle.elevated, + ), + ), + ], + ), + ), + + // Tabel Data + Expanded(child: _buildExcelTable()), + + // Area Tanda Tangan Wali Kelas + _buildSignatureSection(), + ], + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + ), + const Text( + ': ', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + Text( + value, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: Color(0xFF334155), + ), + ), + ], + ); + } + + Widget _buildSignatureSection() { + final now = DateTime.now(); + final tanggal = + '${now.day.toString().padLeft(2, '0')}-${now.month.toString().padLeft(2, '0')}-${now.year}'; + final lokasiTanggal = + widget.wilayah.isNotEmpty ? '${widget.wilayah}, $tanggal' : tanggal; + + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: Colors.black12, width: 1)), + ), + child: Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + lokasiTanggal, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: Color(0xFF334155), + ), + ), + const SizedBox(height: 2), + const Text( + 'Wali Kelas,', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: Color(0xFF334155), + ), + ), + const SizedBox(height: 60), // Ruang tanda tangan + Text( + widget.namaWaliKelas, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + Container( + width: 200, + height: 1, + color: Colors.black54, + ), + const SizedBox(height: 4), + Text( + 'NIP. ${widget.nipWaliKelas}', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF334155), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildExcelTable() { + if (widget.muridList.isEmpty) { + return const Center(child: Text('Tidak ada data murid.', style: TextStyle(fontFamily: 'Inter'))); + } + + return Scrollbar( + thumbVisibility: true, + controller: _verticalScrollController, + child: SingleChildScrollView( + controller: _verticalScrollController, + scrollDirection: Axis.vertical, + child: Scrollbar( + thumbVisibility: true, + controller: _horizontalScrollController, + child: SingleChildScrollView( + controller: _horizontalScrollController, + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: _buildRawDataTable(), + ), + ), + ), + ), + ); + } + + Widget _buildRawDataTable() { + if (widget.muridList.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center(child: Text('Tidak ada data murid.', style: TextStyle(fontFamily: 'Inter', color: Colors.black))), + ); + } + + return DataTable( + showCheckboxColumn: false, + headingRowColor: MaterialStateProperty.all(const Color(0xFFF1F5F9)), + columnSpacing: 16, + horizontalMargin: 8, + dataRowMinHeight: 35, + dataRowMaxHeight: 45, + headingRowHeight: 45, + border: TableBorder.all(color: Colors.grey.withOpacity(0.5), width: 1), + columns: [ + const DataColumn(label: Text('NO', style: TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter', fontSize: 13))), + const DataColumn(label: Text('NIS', style: TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter', fontSize: 13))), + const DataColumn(label: Text('NAMA SISWA', style: TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter', fontSize: 13))), + const DataColumn(label: Text('L/P', style: TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter', fontSize: 13))), + + // Kolom Tanggal 1 s/d 31 (Klik angka tanggal di header untuk pembaruan massal kolom/hari tersebut) + ...List.generate(_daysInMonth, (i) { + final d = i + 1; + return DataColumn( + label: InkWell( + onTap: () { + final allStudents = widget.muridList + .map((m) => m['id_tabel']?.toString()) + .whereType() + .toList(); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => UpdateAbsenBottomSheet( + muridList: widget.muridList, + daysInMonth: _daysInMonth, + yearMonth: widget.yearMonth, + absenIdMap: _absenIdMap, + initialSelectedStudentIds: allStudents, + initialSelectedDays: [d], + onUpdated: _loadData, + ), + ); + }, + child: Container( + width: 22, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text('$d', style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter', fontSize: 13)), + ), + ), + ); + }), + + // Kolom Rekap (Keterangan) + const DataColumn( + label: SizedBox( + width: 22, + child: Center( + child: Text('S', style: TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter', fontSize: 13, color: Colors.blue)), + ), + ), + ), + const DataColumn( + label: SizedBox( + width: 22, + child: Center( + child: Text('I', style: TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter', fontSize: 13, color: Colors.orange)), + ), + ), + ), + const DataColumn( + label: SizedBox( + width: 22, + child: Center( + child: Text('A', style: TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter', fontSize: 13, color: Colors.red)), + ), + ), + ), + ], + rows: List.generate(widget.muridList.length, (index) { + final m = widget.muridList[index]; + final nisStr = m['nis']?.toString(); + final idMurid = m['id_tabel']?.toString(); + + // Cells standar (Identitas - Klik nama murid untuk update massal seluruh bulan/baris) + final cells = [ + DataCell(Text('${index + 1}', style: const TextStyle(fontFamily: 'Inter', fontSize: 12))), + DataCell(Text(nisStr ?? '-', style: const TextStyle(fontFamily: 'Inter', fontSize: 12))), + DataCell( + InkWell( + onTap: () { + if (idMurid == null) return; + final allDays = List.generate(_daysInMonth, (i) => i + 1); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => UpdateAbsenBottomSheet( + muridList: widget.muridList, + daysInMonth: _daysInMonth, + yearMonth: widget.yearMonth, + absenIdMap: _absenIdMap, + initialSelectedStudentIds: [idMurid], + initialSelectedDays: allDays, + onUpdated: _loadData, + ), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + m['nama']?.toString() ?? '-', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF2563EB), + decoration: TextDecoration.underline, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.edit, size: 10, color: Color(0xFF2563EB)), + ], + ), + ), + ), + DataCell( + Center( + child: Text( + m['gender']?.toString() ?? '-', + style: const TextStyle(fontFamily: 'Inter', fontSize: 12), + ), + ), + ), + ]; + + // Cells harian 1-31 + for (int d = 1; d <= _daysInMonth; d++) { + String status = '-'; + if (nisStr != null && _attendanceMap.containsKey(nisStr)) { + status = _attendanceMap[nisStr]![d] ?? '-'; + } + final idTabel = idMurid != null ? _absenIdMap['${idMurid}_$d'] : null; + + cells.add(DataCell( + InkWell( + onTap: idTabel == null || idMurid == null + ? null + : () { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => UpdateAbsenBottomSheet( + muridList: widget.muridList, + daysInMonth: _daysInMonth, + yearMonth: widget.yearMonth, + absenIdMap: _absenIdMap, + initialSelectedStudentIds: [idMurid], + initialSelectedDays: [d], + onUpdated: _loadData, + ), + ); + }, + child: Container( + alignment: Alignment.center, + width: double.infinity, + height: double.infinity, + color: _getCellColor(status), + child: Text( + status, + style: TextStyle( + fontWeight: status != '-' ? FontWeight.bold : FontWeight.normal, + fontFamily: 'Inter', + fontSize: 12, + ), + ), + ), + ) + )); + } + + // Cells rekapitulasi akhir + String sVal = nisStr != null ? _countStatus(nisStr, 'S').toString() : '-'; + String iVal = nisStr != null ? _countStatus(nisStr, 'I').toString() : '-'; + String aVal = nisStr != null ? _countStatus(nisStr, 'A').toString() : '-'; + + cells.add(DataCell(Container(color: Colors.blue.withOpacity(0.1), alignment: Alignment.center, width: double.infinity, height: double.infinity, child: Text(sVal, style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter'))))); + cells.add(DataCell(Container(color: Colors.orange.withOpacity(0.1), alignment: Alignment.center, width: double.infinity, height: double.infinity, child: Text(iVal, style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter'))))); + cells.add(DataCell(Container(color: Colors.red.withOpacity(0.1), alignment: Alignment.center, width: double.infinity, height: double.infinity, child: Text(aVal, style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Inter'))))); + + return DataRow( + cells: cells, + ); + }), + ); + } + + Future _downloadExcel() async { + await AbsensiExcelDownloader.downloadExcel( + context: context, + namaSekolah: _namaSekolah, + monthName: widget.monthName, + yearMonth: widget.yearMonth, + namaKelas: widget.namaKelas, + namaWaliKelas: widget.namaWaliKelas, + nipWaliKelas: widget.nipWaliKelas, + wilayah: widget.wilayah, + muridList: widget.muridList, + daysInMonth: _daysInMonth, + attendanceMap: _attendanceMap, + countStatus: _countStatus, + ); + } + + Future _downloadPdf() async { + try { + final fullPath = await PdfExportService.absensi( + namaSekolah: _namaSekolah, + monthName: widget.monthName, + yearMonth: widget.yearMonth, + namaKelas: widget.namaKelas, + namaWaliKelas: widget.namaWaliKelas, + nipWaliKelas: widget.nipWaliKelas, + wilayah: widget.wilayah, + muridList: widget.muridList, + daysInMonth: _daysInMonth, + attendanceMap: _attendanceMap, + countStatus: _countStatus, + ); + + if (mounted) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row( + children: [ + Icon(Icons.check_circle, color: Colors.green, size: 28), + SizedBox(width: 10), + Expanded( + child: Text( + 'PDF Berhasil Didownload', + style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('File: ${fullPath.split('/').last}', style: const TextStyle(fontFamily: 'Inter', fontSize: 13, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + const Text('Lokasi Penyimpanan:', style: TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.grey)), + Text(fullPath, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.black87)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Tutup', style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + } catch (e) { + rethrow; + } + } +} diff --git a/lib/dashboard/d_guru/function/absen_excel/download.dart b/lib/dashboard/d_guru/function/absen_excel/download.dart new file mode 100644 index 0000000..a7d1924 --- /dev/null +++ b/lib/dashboard/d_guru/function/absen_excel/download.dart @@ -0,0 +1,429 @@ +import 'dart:io'; +import 'package:flutter/material.dart' hide Border, BorderStyle; +import 'package:excel/excel.dart'; +import 'package:apk/dashboard/d_guru/function/absen_excel/storage_helper.dart'; +import 'package:apk/dashboard/d_guru/database/holiday_service.dart'; + +class AbsensiExcelDownloader { + static Future downloadExcel({ + required BuildContext context, + required String namaSekolah, + required String monthName, + required String yearMonth, + required String namaKelas, + required String namaWaliKelas, + required String nipWaliKelas, + required String wilayah, + required List> muridList, + required int daysInMonth, + required Map> attendanceMap, + required int Function(String, String) countStatus, + }) async { + try { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center(child: CircularProgressIndicator()), + ); + + int year = DateTime.now().year; + int month = DateTime.now().month; + final parts = yearMonth.split('-'); + if (parts.length == 2) { + year = int.parse(parts[0]); + month = int.parse(parts[1]); + } + + final excel = Excel.createExcel(); + final sheet = excel['Rekap Absensi']; + excel.setDefaultSheet('Rekap Absensi'); + + // Kop + if (namaSekolah.isNotEmpty) { + sheet.appendRow([TextCellValue(namaSekolah.toUpperCase())]); + } + sheet.appendRow([TextCellValue('ABSENSI SISWA')]); + sheet.appendRow([TextCellValue('BULAN : ${monthName.toUpperCase()}')]); + sheet.appendRow([TextCellValue('')]); + + sheet.appendRow([TextCellValue('Kelas'), TextCellValue(':'), TextCellValue(namaKelas)]); + sheet.appendRow([TextCellValue('Jumlah Murid'), TextCellValue(':'), TextCellValue('${muridList.length} Siswa')]); + sheet.appendRow([TextCellValue('')]); + + // Header Table + List headerRow1 = [ + TextCellValue('NO'), + TextCellValue('NIS'), + TextCellValue('NAMA SISWA'), + TextCellValue('TANGGAL'), + ]; + for (int i = 2; i <= daysInMonth; i++) { + headerRow1.add(TextCellValue('')); + } + headerRow1.add(TextCellValue('KET')); + for (int i = 1; i < 4; i++) { + headerRow1.add(TextCellValue('')); + } + sheet.appendRow(headerRow1); + + List headerRow2 = [ + TextCellValue(''), + TextCellValue(''), + TextCellValue(''), + ]; + for (int i = 1; i <= daysInMonth; i++) { + headerRow2.add(IntCellValue(i)); + } + headerRow2.addAll([ + TextCellValue('H'), + TextCellValue('I'), + TextCellValue('S'), + TextCellValue('A'), + ]); + sheet.appendRow(headerRow2); + + // Merge cells for header + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 7), CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 8)); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 7), CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 8)); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: 7), CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: 8)); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 7), CellIndex.indexByColumnRow(columnIndex: 3 + daysInMonth - 1, rowIndex: 7)); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 3 + daysInMonth, rowIndex: 7), CellIndex.indexByColumnRow(columnIndex: 3 + daysInMonth + 3, rowIndex: 7)); + + // Data Rows + for (int index = 0; index < muridList.length; index++) { + final m = muridList[index]; + final nisStr = m['nis']?.toString(); + + List row = [ + IntCellValue(index + 1), + nisStr != null ? TextCellValue(nisStr) : TextCellValue('-'), + TextCellValue(m['nama']?.toString() ?? '-'), + ]; + + for (int d = 1; d <= daysInMonth; d++) { + String status = '-'; + if (nisStr != null && attendanceMap.containsKey(nisStr)) { + final studentMap = attendanceMap[nisStr]!; + status = studentMap[d]?.toString() ?? studentMap[d.toString()]?.toString() ?? '-'; + } + row.add(TextCellValue(status)); + } + + String hVal = nisStr != null ? countStatus(nisStr, 'H').toString() : '-'; + String iVal = nisStr != null ? countStatus(nisStr, 'I').toString() : '-'; + String sVal = nisStr != null ? countStatus(nisStr, 'S').toString() : '-'; + String aVal = nisStr != null ? countStatus(nisStr, 'A').toString() : '-'; + + row.addAll([ + TextCellValue(hVal), + TextCellValue(iVal), + TextCellValue(sVal), + TextCellValue(aVal), + ]); + + sheet.appendRow(row); + } + + // Signature + int totalColumns = 3 + daysInMonth + 4; + int signColumnIndex = 3 + daysInMonth - 2; + + final now = DateTime.now(); + final tanggal = + '${now.day.toString().padLeft(2, '0')}-${now.month.toString().padLeft(2, '0')}-${now.year}'; + final lokasiTanggal = + wilayah.isNotEmpty ? '$wilayah, $tanggal' : tanggal; + final nipText = 'NIP. $nipWaliKelas'; + + List emptyRow = + List.generate(totalColumns, (index) => TextCellValue('')); + + // Baris 1: wilayah + tanggal + List signRow0 = + List.generate(totalColumns, (index) => TextCellValue('')); + signRow0[signColumnIndex] = TextCellValue(lokasiTanggal); + + // Baris 2: jabatan + List signRow1 = + List.generate(totalColumns, (index) => TextCellValue('')); + signRow1[signColumnIndex] = TextCellValue('Wali Kelas,'); + + // Baris nama (bold + underline) — muncul setelah ruang tanda tangan + List signRow2 = + List.generate(totalColumns, (index) => TextCellValue('')); + signRow2[signColumnIndex] = TextCellValue(namaWaliKelas); + + // Baris NIP + List signRow3 = + List.generate(totalColumns, (index) => TextCellValue('')); + signRow3[signColumnIndex] = TextCellValue(nipText); + + sheet.appendRow(emptyRow); // pemisah dari tabel + sheet.appendRow(signRow0); // wilayah, tanggal + sheet.appendRow(signRow1); // Wali Kelas, + sheet.appendRow(emptyRow); // ruang tanda tangan + sheet.appendRow(emptyRow); + sheet.appendRow(emptyRow); + sheet.appendRow(signRow2); // nama (di atas garis — bold) + sheet.appendRow(signRow3); // NIP + + // ========================= + // STYLE & BORDERS + // ========================= + + final sideBorder = Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.black, + ); + + final kopStyle = CellStyle( + bold: true, + horizontalAlign: HorizontalAlign.Center, + verticalAlign: VerticalAlign.Center, + ); + + final headerStyle = CellStyle( + bold: true, + horizontalAlign: HorizontalAlign.Center, + verticalAlign: VerticalAlign.Center, + leftBorder: sideBorder, + rightBorder: sideBorder, + topBorder: sideBorder, + bottomBorder: sideBorder, + textWrapping: TextWrapping.WrapText, + ); + + final dataCenterStyle = CellStyle( + horizontalAlign: HorizontalAlign.Center, + verticalAlign: VerticalAlign.Center, + leftBorder: sideBorder, + rightBorder: sideBorder, + topBorder: sideBorder, + bottomBorder: sideBorder, + textWrapping: TextWrapping.WrapText, + ); + + final sundayStyle = CellStyle( + fontColorHex: ExcelColor.red, + backgroundColorHex: ExcelColor.red100, + horizontalAlign: HorizontalAlign.Center, + verticalAlign: VerticalAlign.Center, + leftBorder: sideBorder, + rightBorder: sideBorder, + topBorder: sideBorder, + bottomBorder: sideBorder, + textWrapping: TextWrapping.WrapText, + ); + + final dataLeftStyle = CellStyle( + horizontalAlign: HorizontalAlign.Left, + verticalAlign: VerticalAlign.Center, + leftBorder: sideBorder, + rightBorder: sideBorder, + topBorder: sideBorder, + bottomBorder: sideBorder, + textWrapping: TextWrapping.WrapText, + ); + + // Merge Kop (Baris 1, 2, 3) + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0), CellIndex.indexByColumnRow(columnIndex: totalColumns - 1, rowIndex: 0)); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 1), CellIndex.indexByColumnRow(columnIndex: totalColumns - 1, rowIndex: 1)); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 2), CellIndex.indexByColumnRow(columnIndex: totalColumns - 1, rowIndex: 2)); + + // Apply Kop Style + for (int i = 0; i < 3; i++) { + sheet.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: i)).cellStyle = kopStyle; + } + + // Apply Header Table Style (baris 8 dan 9 -> row index 7 dan 8) + for (int r = 7; r <= 8; r++) { + for (int c = 0; c < totalColumns; c++) { + sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: r)).cellStyle = headerStyle; + } + } + + // Override Sunday & Holiday Headers (Row index 8) + for (int i = 1; i <= daysInMonth; i++) { + final date = DateTime(year, month, i); + if (date.weekday == DateTime.sunday || HolidayService.isHoliday(date)) { + int columnIndex = 3 + i - 1; + sheet.cell(CellIndex.indexByColumnRow(columnIndex: columnIndex, rowIndex: 8)).cellStyle = CellStyle( + bold: true, + fontColorHex: ExcelColor.red, + backgroundColorHex: ExcelColor.red100, + horizontalAlign: HorizontalAlign.Center, + verticalAlign: VerticalAlign.Center, + leftBorder: sideBorder, + rightBorder: sideBorder, + topBorder: sideBorder, + bottomBorder: sideBorder, + ); + } + } + + // Apply Data Rows Style (mulai dari row index 9) + int startDataRow = 9; + for (int i = 0; i < muridList.length; i++) { + int r = startDataRow + i; + + // NO + sheet.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: r)).cellStyle = dataCenterStyle; + // NIS + sheet.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: r)).cellStyle = dataCenterStyle; + // NAMA SISWA + sheet.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: r)).cellStyle = dataLeftStyle; + + // Attendance & Rekap (H, I, S, A) + for (int c = 3; c < totalColumns; c++) { + bool isSundayOrHoliday = false; + if (c < 3 + daysInMonth) { + int d = c - 2; + final date = DateTime(year, month, d); + isSundayOrHoliday = date.weekday == DateTime.sunday || HolidayService.isHoliday(date); + } + + if (isSundayOrHoliday) { + sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: r)).cellStyle = sundayStyle; + } else { + sheet.cell(CellIndex.indexByColumnRow(columnIndex: c, rowIndex: r)).cellStyle = dataCenterStyle; + } + } + } + + // Apply Signature Style + final signNormalStyle = CellStyle( + horizontalAlign: HorizontalAlign.Left, + verticalAlign: VerticalAlign.Center, + fontSize: 11, + ); + + final signNameStyle = CellStyle( + bold: true, + underline: Underline.Single, + horizontalAlign: HorizontalAlign.Left, + verticalAlign: VerticalAlign.Center, + fontSize: 11, + ); + + int signStartRow = startDataRow + muridList.length; + // +0: empty separator + // +1: wilayah, tanggal + // +2: Wali Kelas, + // +3,+4,+5: empty (signature space) + // +6: nama (bold+underline) + // +7: NIP + sheet.cell(CellIndex.indexByColumnRow(columnIndex: signColumnIndex, rowIndex: signStartRow + 1)).cellStyle = signNormalStyle; + sheet.cell(CellIndex.indexByColumnRow(columnIndex: signColumnIndex, rowIndex: signStartRow + 2)).cellStyle = signNormalStyle; + sheet.cell(CellIndex.indexByColumnRow(columnIndex: signColumnIndex, rowIndex: signStartRow + 6)).cellStyle = signNameStyle; + sheet.cell(CellIndex.indexByColumnRow(columnIndex: signColumnIndex, rowIndex: signStartRow + 7)).cellStyle = signNormalStyle; + + // ========================= + // UKURAN KOLOM + // ========================= + + sheet.setColumnWidth(0, 6); // NO + sheet.setColumnWidth(1, 15); // NIS + sheet.setColumnWidth(2, 30); // NAMA SISWA + + // Tanggal (Harian) + for (int d = 1; d <= daysInMonth; d++) { + int columnIndex = 3 + d - 1; + bool hasTime = false; + for (var m in muridList) { + final nisStr = m['nis']?.toString(); + if (nisStr != null && attendanceMap.containsKey(nisStr)) { + final status = attendanceMap[nisStr]![d] ?? '-'; + if (status.length > 1) { + hasTime = true; + break; + } + } + } + sheet.setColumnWidth(columnIndex, hasTime ? 15 : 5); + } + + // Rekap columns (H, I, S, A) + for (int i = 3 + daysInMonth; i < totalColumns; i++) { + sheet.setColumnWidth(i, 5); + } + + // ========================= + // SAVE FILE + // ========================= + + final downloadDir = await StorageHelper.getDownloadDirectoryPath(); + final baseName = 'Absensi_${namaKelas.replaceAll('/', '_')}_${monthName.replaceAll(' ', '_')}'; + + var finalPath = StorageHelper.getUniqueFilePath(downloadDir, baseName, 'xlsx'); + var finalFileName = finalPath.split('/').last; + + final fileBytes = excel.save(); + + if (fileBytes != null) { + try { + final file = File(finalPath); + file.createSync(recursive: true); + file.writeAsBytesSync(fileBytes); + } catch (e) { + final nowTime = DateTime.now(); + final timestamp = '${nowTime.hour.toString().padLeft(2, '0')}${nowTime.minute.toString().padLeft(2, '0')}${nowTime.second.toString().padLeft(2, '0')}'; + final fallbackFileName = '${baseName}_$timestamp.xlsx'; + finalPath = '$downloadDir/$fallbackFileName'; + finalFileName = fallbackFileName; + final file = File(finalPath); + file.createSync(recursive: true); + file.writeAsBytesSync(fileBytes); + } + } + + if (context.mounted) { + Navigator.pop(context); // Tutup loading dialog + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row( + children: [ + Icon(Icons.check_circle, color: Colors.green, size: 28), + SizedBox(width: 10), + Expanded( + child: Text( + 'Excel Berhasil Didownload', + style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('File: $finalFileName', style: const TextStyle(fontFamily: 'Inter', fontSize: 13, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + const Text('Lokasi Penyimpanan:', style: TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.grey)), + Text(finalPath, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.black87)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Tutup', style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + } catch (e) { + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal menyimpan Excel: $e'), + backgroundColor: Colors.red.shade600, + ), + ); + } + } + } +} diff --git a/lib/dashboard/d_guru/function/absen_excel/pdf/absen_baru_pdf.dart b/lib/dashboard/d_guru/function/absen_excel/pdf/absen_baru_pdf.dart new file mode 100644 index 0000000..0c0a10e --- /dev/null +++ b/lib/dashboard/d_guru/function/absen_excel/pdf/absen_baru_pdf.dart @@ -0,0 +1,184 @@ +import 'dart:io'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:intl/intl.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import '../storage_helper.dart'; +import 'pdf_components.dart'; + +Future exportAbsenBaruPdf({ + required String judul, + required String date, + required List> students, +}) async { + await initializeDateFormatting('id_ID', null); + + String formattedDate = date; + try { + final parsed = DateTime.parse(date); + formattedDate = DateFormat('dd MMMM yyyy', 'id_ID').format(parsed); + } catch (_) {} + + final pdf = pw.Document(); + + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + header: (context) => buildPdfHeader('LAPORAN ABSENSI SESI', DateTime.now()), + footer: (context) => buildPdfFooter(context), + build: (context) { + return [ + // Meta info + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _buildMeta('Judul', judul), + pw.SizedBox(height: 2), + _buildMeta('Tanggal', formattedDate), + ], + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + _buildMeta('Jumlah Siswa', '${students.length}'), + ], + ), + ], + ), + pw.SizedBox(height: 12), + + // Section title + buildSectionTitle('Daftar Absensi Siswa'), + pw.SizedBox(height: 8), + + // Table + _buildStudentTable(students), + pw.SizedBox(height: 24), + + // Signature block + pw.Align( + alignment: pw.Alignment.bottomRight, + child: pw.Container( + width: 160, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text('Guru Pengajar,', style: const pw.TextStyle(fontSize: 8)), + pw.SizedBox(height: 40), + pw.Container(height: 0.5, color: PdfColors.black), + pw.SizedBox(height: 3), + pw.Text('(..........................)', style: pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold)), + ], + ), + ), + ), + ]; + }, + ), + ); + + // Save file + final dirPath = await StorageHelper.getDownloadDirectoryPath(); + final sanitizedJudul = judul.replaceAll(RegExp(r'[^\w\s]'), '').replaceAll(' ', '_'); + final sanitizedDate = date.replaceAll('-', ''); + final baseName = 'Absensi_${sanitizedJudul}_$sanitizedDate'; + var finalPath = StorageHelper.getUniqueFilePath(dirPath, baseName, 'pdf'); + var file = File(finalPath); + + final bytes = await pdf.save(); + try { + await file.writeAsBytes(bytes, flush: true); + } catch (e) { + final nowTime = DateTime.now(); + final timestamp = '${nowTime.hour.toString().padLeft(2, '0')}${nowTime.minute.toString().padLeft(2, '0')}${nowTime.second.toString().padLeft(2, '0')}'; + final fallbackFileName = '${baseName}_$timestamp.pdf'; + finalPath = '$dirPath/$fallbackFileName'; + file = File(finalPath); + await file.writeAsBytes(bytes, flush: true); + } + + return finalPath; +} + +pw.Widget _buildMeta(String label, String value) { + return pw.RichText( + text: pw.TextSpan( + style: const pw.TextStyle(fontSize: 9), + children: [ + pw.TextSpan( + text: '$label: ', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: kPdfPrimary), + ), + pw.TextSpan(text: value), + ], + ), + ); +} + +pw.Widget _buildStudentTable(List> students) { + final headers = ['NO', 'NIS', 'NAMA SISWA', 'KETERANGAN']; + + final List> rows = []; + for (var s in students) { + final ket = s['keterangan']?.toString() ?? '-'; + PdfColor textColor = PdfColors.black; + PdfColor? bgColor; + + switch (ket) { + case 'Hadir': + textColor = PdfColors.green; + bgColor = const PdfColor.fromInt(0xFFE8F5E9); + break; + case 'Sakit': + textColor = PdfColors.blue; + bgColor = const PdfColor.fromInt(0xFFE3F2FD); + break; + case 'Izin': + textColor = PdfColors.orange; + bgColor = const PdfColor.fromInt(0xFFFFF3E0); + break; + case 'Alpha': + textColor = PdfColors.red; + bgColor = const PdfColor.fromInt(0xFFFFEBEE); + break; + case 'Libur': + textColor = PdfColors.purple; + bgColor = const PdfColor.fromInt(0xFFF3E5F5); + break; + } + + rows.add([ + pw.Center(child: pw.Text('${s['no']}', style: const pw.TextStyle(fontSize: 8))), + pw.Center(child: pw.Text(s['nis']?.toString() ?? '-', style: const pw.TextStyle(fontSize: 8))), + pw.Text(s['nama']?.toString() ?? '-', style: const pw.TextStyle(fontSize: 8), maxLines: 1), + pw.Container( + color: bgColor, + alignment: pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: pw.Text( + ket, + style: pw.TextStyle( + fontSize: 8, + fontWeight: pw.FontWeight.bold, + color: textColor, + ), + ), + ), + ]); + } + + return buildDataTable( + headers: headers, + rows: rows, + colWidths: { + 0: const pw.FixedColumnWidth(30), // NO + 1: const pw.FixedColumnWidth(60), // NIS + 2: const pw.FlexColumnWidth(3), // NAMA SISWA + 3: const pw.FixedColumnWidth(80), // KETERANGAN + }, + ); +} diff --git a/lib/dashboard/d_guru/function/absen_excel/pdf/absen_pdf_builder.dart b/lib/dashboard/d_guru/function/absen_excel/pdf/absen_pdf_builder.dart new file mode 100644 index 0000000..ea4c1eb --- /dev/null +++ b/lib/dashboard/d_guru/function/absen_excel/pdf/absen_pdf_builder.dart @@ -0,0 +1,281 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:intl/date_symbol_data_local.dart'; +import '../storage_helper.dart'; +import 'pdf_components.dart'; +import 'package:apk/dashboard/d_guru/database/holiday_service.dart'; + +pw.Widget _buildMetaText(String label, String value) { + return pw.RichText( + text: pw.TextSpan( + style: const pw.TextStyle(fontSize: 8), + children: [ + pw.TextSpan( + text: '$label: ', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: kPdfPrimary), + ), + pw.TextSpan(text: value), + ], + ), + ); +} + +pw.Widget _buildAttendanceTable({ + required int year, + required int month, + required List> muridList, + required int daysInMonth, + required Map> attendanceMap, + required int Function(String, String) countStatus, +}) { + // Headers + List headers = ['NO', 'NIS', 'NAMA SISWA']; + for (int i = 1; i <= daysInMonth; i++) { + headers.add('$i'); + } + headers.addAll(['H', 'I', 'S', 'A']); + + // Rows + List> rows = []; + for (int index = 0; index < muridList.length; index++) { + final m = muridList[index]; + final nisStr = m['nis']?.toString(); + + List rowCells = [ + pw.Center(child: pw.Text('${index + 1}', style: const pw.TextStyle(fontSize: 6.5))), + pw.Center(child: pw.Text(nisStr ?? '-', style: const pw.TextStyle(fontSize: 6.5))), + pw.Text(m['nama']?.toString() ?? '-', style: const pw.TextStyle(fontSize: 6.5), maxLines: 1), + ]; + + for (int d = 1; d <= daysInMonth; d++) { + String status = '-'; + if (nisStr != null && attendanceMap.containsKey(nisStr)) { + final studentMap = attendanceMap[nisStr]!; + status = studentMap[d]?.toString() ?? studentMap[d.toString()]?.toString() ?? '-'; + } + + final date = DateTime(year, month, d); + final isSundayOrHoliday = date.weekday == DateTime.sunday || HolidayService.isHoliday(date); + + PdfColor? cellColor; + PdfColor textColor = PdfColors.black; + if (isSundayOrHoliday) { + cellColor = PdfColor.fromInt(0xFFFFEBEE); // light red + textColor = PdfColors.red; + } else { + switch (status) { + case 'H': cellColor = PdfColor.fromInt(0xFFE8F5E9); break; // light green + case 'S': cellColor = PdfColor.fromInt(0xFFE3F2FD); break; // light blue + case 'I': cellColor = PdfColor.fromInt(0xFFFFF3E0); break; // light orange + case 'A': cellColor = PdfColor.fromInt(0xFFFFEBEE); break; // light red + } + } + + rowCells.add( + pw.Container( + color: cellColor, + alignment: pw.Alignment.center, + child: pw.Text( + status, + style: pw.TextStyle( + fontSize: 6.5, + color: textColor, + fontWeight: status != '-' ? pw.FontWeight.bold : pw.FontWeight.normal, + ), + ), + ), + ); + } + + String hVal = nisStr != null ? countStatus(nisStr, 'H').toString() : '-'; + String iVal = nisStr != null ? countStatus(nisStr, 'I').toString() : '-'; + String sVal = nisStr != null ? countStatus(nisStr, 'S').toString() : '-'; + String aVal = nisStr != null ? countStatus(nisStr, 'A').toString() : '-'; + + rowCells.addAll([ + pw.Container( + color: PdfColor.fromInt(0xFFE8F5E9), + alignment: pw.Alignment.center, + child: pw.Text(hVal, style: pw.TextStyle(fontSize: 6.5, fontWeight: pw.FontWeight.bold, color: PdfColors.green)), + ), + pw.Container( + color: PdfColor.fromInt(0xFFFFF3E0), + alignment: pw.Alignment.center, + child: pw.Text(iVal, style: pw.TextStyle(fontSize: 6.5, fontWeight: pw.FontWeight.bold, color: PdfColors.orange)), + ), + pw.Container( + color: PdfColor.fromInt(0xFFE3F2FD), + alignment: pw.Alignment.center, + child: pw.Text(sVal, style: pw.TextStyle(fontSize: 6.5, fontWeight: pw.FontWeight.bold, color: PdfColors.blue)), + ), + pw.Container( + color: PdfColor.fromInt(0xFFFFEBEE), + alignment: pw.Alignment.center, + child: pw.Text(aVal, style: pw.TextStyle(fontSize: 6.5, fontWeight: pw.FontWeight.bold, color: PdfColors.red)), + ), + ]); + + rows.add(rowCells); + } + + // Column Widths + Map colWidths = { + 0: const pw.FixedColumnWidth(16), // NO + 1: const pw.FixedColumnWidth(40), // NIS + 2: const pw.FixedColumnWidth(95), // NAMA SISWA + }; + for (int i = 3; i < 3 + daysInMonth; i++) { + colWidths[i] = const pw.FixedColumnWidth(14); // Days 1-31 + } + for (int i = 3 + daysInMonth; i < 3 + daysInMonth + 4; i++) { + colWidths[i] = const pw.FixedColumnWidth(15); // H, I, S, A + } + + return buildDataTable( + headers: headers, + rows: rows, + colWidths: colWidths, + ); +} + +Future exportAbsensiPdf({ + required String namaSekolah, + required String monthName, + required String yearMonth, + required String namaKelas, + required String namaWaliKelas, + required String nipWaliKelas, + required String wilayah, + required List> muridList, + required int daysInMonth, + required Map> attendanceMap, + required int Function(String, String) countStatus, +}) async { + try { + // Initialize Indonesian locale formatting + await initializeDateFormatting('id_ID', null); + + // Parse year & month + int year = DateTime.now().year; + int month = DateTime.now().month; + final parts = yearMonth.split('-'); + if (parts.length == 2) { + year = int.parse(parts[0]); + month = int.parse(parts[1]); + } + + // Generate date & location text + final now = DateTime.now(); + final tanggal = '${now.day.toString().padLeft(2, '0')}-${now.month.toString().padLeft(2, '0')}-${now.year}'; + final lokasiTanggal = wilayah.isNotEmpty ? '$wilayah, $tanggal' : tanggal; + + final pdf = pw.Document(); + + // Determine the month text header dynamically + final displayMonth = monthName.toUpperCase().contains(year.toString()) + ? monthName.toUpperCase() + : '${monthName.toUpperCase()} $year'; + + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4.landscape, + margin: const pw.EdgeInsets.symmetric(vertical: 24, horizontal: 24), + header: (context) => buildPdfHeader('LAPORAN BULANAN ABSENSI SISWA', DateTime.now()), + footer: (context) => buildPdfFooter(context), + build: (context) { + return [ + // Subtitle / School details + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _buildMetaText('Sekolah', namaSekolah.toUpperCase()), + pw.SizedBox(height: 2), + _buildMetaText('Kelas', namaKelas), + pw.SizedBox(height: 2), + _buildMetaText('Bulan', displayMonth), + ], + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + _buildMetaText('Wali Kelas', namaWaliKelas), + pw.SizedBox(height: 2), + _buildMetaText('Jumlah Murid', '${muridList.length} Siswa'), + ], + ), + ], + ), + pw.SizedBox(height: 10), + + // Table Header Title + buildSectionTitle('Tabel Absensi Harian & Rekapitulasi'), + pw.SizedBox(height: 6), + + // Table + _buildAttendanceTable( + year: year, + month: month, + muridList: muridList, + daysInMonth: daysInMonth, + attendanceMap: attendanceMap, + countStatus: countStatus, + ), + pw.SizedBox(height: 16), + + // Signature Block + pw.Align( + alignment: pw.Alignment.bottomRight, + child: pw.Container( + width: 160, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(lokasiTanggal, style: const pw.TextStyle(fontSize: 8)), + pw.SizedBox(height: 2), + pw.Text('Wali Kelas,', style: const pw.TextStyle(fontSize: 8)), + pw.SizedBox(height: 40), + pw.Text(namaWaliKelas, style: pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold)), + pw.SizedBox(height: 2), + pw.Container(height: 0.5, color: PdfColors.black), + pw.SizedBox(height: 2), + pw.Text('NIP. $nipWaliKelas', style: const pw.TextStyle(fontSize: 8)), + ], + ), + ), + ), + ]; + }, + ), + ); + + final dirPath = await StorageHelper.getDownloadDirectoryPath(); + final baseName = 'Absensi_${namaKelas.replaceAll('/', '_')}_${monthName.replaceAll(' ', '_')}'; + var finalPath = StorageHelper.getUniqueFilePath(dirPath, baseName, 'pdf'); + var file = File(finalPath); + + final bytes = await pdf.save(); + try { + await file.writeAsBytes(bytes, flush: true); + } catch (e) { + final nowTime = DateTime.now(); + final timestamp = '${nowTime.hour.toString().padLeft(2, '0')}${nowTime.minute.toString().padLeft(2, '0')}${nowTime.second.toString().padLeft(2, '0')}'; + final fallbackFileName = '${baseName}_$timestamp.pdf'; + finalPath = '$dirPath/$fallbackFileName'; + file = File(finalPath); + await file.writeAsBytes(bytes, flush: true); + } + + return finalPath; + } catch (e, stack) { + debugPrint('=== PDF EXPORT RUNTIME ERROR ==='); + debugPrint(e.toString()); + debugPrint(stack.toString()); + rethrow; + } +} diff --git a/lib/dashboard/d_guru/function/absen_excel/pdf/export_pdf_button.dart b/lib/dashboard/d_guru/function/absen_excel/pdf/export_pdf_button.dart new file mode 100644 index 0000000..b4b0dae --- /dev/null +++ b/lib/dashboard/d_guru/function/absen_excel/pdf/export_pdf_button.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +/// ============================================================ +/// Export PDF Button — Reusable Widget +/// Letakkan di: lib/function/f_guru/absen_excel/pdf/export_pdf_button.dart +/// ============================================================ + +class ExportPdfButton extends StatefulWidget { + /// Callback async yang memanggil PdfExportService + final Future Function() onExport; + + /// Label tombol (default: "Download PDF") + final String label; + + /// Style: FAB (floating) atau ElevatedButton (inline) + final ExportButtonStyle style; + + const ExportPdfButton({ + super.key, + required this.onExport, + this.label = 'Download PDF', + this.style = ExportButtonStyle.elevated, + }); + + @override + State createState() => _ExportPdfButtonState(); +} + +class _ExportPdfButtonState extends State { + bool _isLoading = false; + + Future _handleExport() async { + if (_isLoading) return; + setState(() => _isLoading = true); + + try { + await widget.onExport(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal download PDF: $e'), + backgroundColor: Colors.red.shade600, + ), + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return widget.style == ExportButtonStyle.fab + ? _buildFab() + : _buildElevated(); + } + + // — FAB style (floating action button) + Widget _buildFab() { + return FloatingActionButton.extended( + onPressed: _isLoading ? null : _handleExport, + backgroundColor: const Color(0xFFEF4444), // Red for PDF + icon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.picture_as_pdf, color: Colors.white), + label: Text( + _isLoading ? 'Menyiapkan...' : widget.label, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontFamily: 'Inter'), + ), + ); + } + + // — Elevated button style (inline di dalam konten) + Widget _buildElevated() { + return ElevatedButton.icon( + onPressed: _isLoading ? null : _handleExport, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFEF4444), // Red for PDF + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: _isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.picture_as_pdf, color: Colors.white, size: 18), + label: Text( + _isLoading ? 'Menyiapkan PDF...' : widget.label, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + fontFamily: 'Inter', + ), + ), + ); + } +} + +enum ExportButtonStyle { fab, elevated } diff --git a/lib/dashboard/d_guru/function/absen_excel/pdf/pdf_components.dart b/lib/dashboard/d_guru/function/absen_excel/pdf/pdf_components.dart new file mode 100644 index 0000000..85fe806 --- /dev/null +++ b/lib/dashboard/d_guru/function/absen_excel/pdf/pdf_components.dart @@ -0,0 +1,288 @@ +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:intl/intl.dart'; + +// ─── Warna tema ─────────────────────────────────────────────── +const kPdfPrimary = PdfColor.fromInt(0xFF1565C0); +const kPdfAccent = PdfColor.fromInt(0xFF42A5F5); +const kPdfRowEven = PdfColor.fromInt(0xFFF5F9FF); +const kPdfBorder = PdfColor.fromInt(0xFFBBDEFB); + +// ─── Format tanggal ─────────────────────────────────────────── +final pdfDateFormat = DateFormat('dd MMMM yyyy', 'id_ID'); +final pdfTimeFormat = DateFormat('HH:mm:ss'); +final pdfFullFormat = DateFormat('dd MMM yyyy, HH:mm', 'id_ID'); + +// ================================================================ +// HEADER — muncul di setiap halaman +// ================================================================ +pw.Widget buildPdfHeader(String title, DateTime date) { + return pw.Container( + margin: const pw.EdgeInsets.only(bottom: 16), + decoration: const pw.BoxDecoration( + border: pw.Border( + bottom: pw.BorderSide(color: kPdfBorder, width: 1.5), + ), + ), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + title, + style: pw.TextStyle( + fontSize: 16, + fontWeight: pw.FontWeight.bold, + color: kPdfPrimary, + ), + ), + pw.SizedBox(height: 2), + pw.Text( + 'Sistem Informasi Akademik & Absensi', + style: pw.TextStyle(fontSize: 9, color: PdfColors.grey600), + ), + ], + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text( + pdfDateFormat.format(date), + style: pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + color: kPdfPrimary, + ), + ), + pw.Text( + pdfTimeFormat.format(date), + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey600), + ), + ], + ), + ], + ), + ); +} + +// ================================================================ +// FOOTER — muncul di setiap halaman +// ================================================================ +pw.Widget buildPdfFooter(pw.Context context) { + return pw.Container( + margin: const pw.EdgeInsets.only(top: 12), + decoration: const pw.BoxDecoration( + border: pw.Border(top: pw.BorderSide(color: kPdfBorder, width: 1)), + ), + child: pw.Padding( + padding: const pw.EdgeInsets.only(top: 6), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'Digenerate otomatis oleh Aplikasi Akademik', + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey500), + ), + pw.Text( + 'Halaman ${context.pageNumber} / ${context.pagesCount}', + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey500), + ), + ], + ), + ), + ); +} + +// ================================================================ +// SECTION TITLE — judul bagian dengan background biru +// ================================================================ +pw.Widget buildSectionTitle(String title) { + return pw.Container( + padding: const pw.EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: const pw.BoxDecoration( + color: kPdfPrimary, + borderRadius: pw.BorderRadius.all(pw.Radius.circular(4)), + ), + child: pw.Text( + title, + style: pw.TextStyle( + fontSize: 11, + fontWeight: pw.FontWeight.bold, + color: PdfColors.white, + ), + ), + ); +} + +// ================================================================ +// SUMMARY CARD — grid kartu nilai terkini +// ================================================================ +pw.Widget buildSummaryCard({ + required String title, + required String subtitle, + required List items, +}) { + return pw.Container( + padding: const pw.EdgeInsets.all(12), + decoration: pw.BoxDecoration( + color: const PdfColor.fromInt(0xFFE3F2FD), + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)), + border: pw.Border.all(color: kPdfBorder), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + title, + style: pw.TextStyle( + fontSize: 11, + fontWeight: pw.FontWeight.bold, + color: kPdfPrimary, + ), + ), + pw.Text( + subtitle, + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey600), + ), + pw.SizedBox(height: 8), + pw.Wrap( + spacing: 10, + runSpacing: 6, + children: items.map((item) => _summaryItemCard(item)).toList(), + ), + ], + ), + ); +} + +pw.Widget _summaryItemCard(SummaryItem item) { + return pw.Container( + width: 140, + padding: const pw.EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: pw.BoxDecoration( + color: PdfColors.white, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), + border: pw.Border.all(color: kPdfBorder), + ), + child: pw.Row( + children: [ + pw.Text(item.emoji, style: const pw.TextStyle(fontSize: 14)), + pw.SizedBox(width: 6), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + item.label, + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey600), + ), + pw.Text( + item.value, + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + color: kPdfPrimary, + ), + ), + ], + ), + ], + ), + ); +} + +// ================================================================ +// BASE TABLE — semua tabel pakai ini +// ================================================================ +pw.Widget buildDataTable({ + required List headers, + required List> rows, + Map? colWidths, +}) { + return pw.Table( + border: pw.TableBorder.all(color: kPdfBorder, width: 0.5), + columnWidths: colWidths, + children: [ + // Header + pw.TableRow( + decoration: const pw.BoxDecoration(color: kPdfPrimary), + children: headers + .map((h) => pw.Padding( + padding: const pw.EdgeInsets.symmetric( + vertical: 5, horizontal: 4), + child: pw.Align( + alignment: pw.Alignment.center, + child: pw.Text( + h, + style: pw.TextStyle( + fontSize: 8, + fontWeight: pw.FontWeight.bold, + color: PdfColors.white, + ), + ), + ), + )) + .toList(), + ), + // Data rows + ...rows.asMap().entries.map((entry) { + final isEven = entry.key % 2 == 0; + return pw.TableRow( + decoration: pw.BoxDecoration( + color: isEven ? kPdfRowEven : PdfColors.white, + ), + children: entry.value + .map((cell) => pw.Padding( + padding: const pw.EdgeInsets.symmetric( + vertical: 3, horizontal: 4), + child: cell, + )) + .toList(), + ); + }), + ], + ); +} + +// ================================================================ +// KEY-VALUE TABLE — tabel 3 kolom: Parameter | Nilai | Satuan +// ================================================================ +pw.Widget buildKeyValueTable(List> rows) { + final headerRow = rows.first; + final dataRows = rows.skip(1).toList(); + + final List> widgetRows = dataRows.map((r) { + return r.map((cellText) => pw.Text(cellText, style: const pw.TextStyle(fontSize: 8))).toList(); + }).toList(); + + return buildDataTable( + headers: headerRow, + rows: widgetRows, + colWidths: { + 0: const pw.FlexColumnWidth(2), + 1: const pw.FlexColumnWidth(1.5), + 2: const pw.FlexColumnWidth(1), + }, + ); +} + +// ================================================================ +// HELPER CLASS +// ================================================================ +class SummaryItem { + final String label; + final String value; + final String emoji; + const SummaryItem(this.label, this.value, this.emoji); +} + +// ================================================================ +// STATS HELPER +// ================================================================ +Map calcStats(List data) { + if (data.isEmpty) return {'avg': 0, 'max': 0, 'min': 0}; + final sorted = List.from(data)..sort(); + final avg = data.reduce((a, b) => a + b) / data.length; + return {'avg': avg, 'max': sorted.last, 'min': sorted.first}; +} diff --git a/lib/dashboard/d_guru/function/absen_excel/pdf/pdf_export_service.dart b/lib/dashboard/d_guru/function/absen_excel/pdf/pdf_export_service.dart new file mode 100644 index 0000000..8cae4b3 --- /dev/null +++ b/lib/dashboard/d_guru/function/absen_excel/pdf/pdf_export_service.dart @@ -0,0 +1,40 @@ +import 'absen_pdf_builder.dart'; + +/// ============================================================ +/// PDF Export Service — Entry Point +/// Letakkan di: lib/function/f_guru/absen_excel/pdf/pdf_export_service.dart +/// +/// Ini cuma facade — semua logic ada di builder masing-masing. +/// Import file ini saja di screen kamu. +/// ============================================================ + +class PdfExportService { + PdfExportService._(); // tidak bisa di-instantiate + + static Future absensi({ + required String namaSekolah, + required String monthName, + required String yearMonth, + required String namaKelas, + required String namaWaliKelas, + required String nipWaliKelas, + required String wilayah, + required List> muridList, + required int daysInMonth, + required Map> attendanceMap, + required int Function(String, String) countStatus, + }) => + exportAbsensiPdf( + namaSekolah: namaSekolah, + monthName: monthName, + yearMonth: yearMonth, + namaKelas: namaKelas, + namaWaliKelas: namaWaliKelas, + nipWaliKelas: nipWaliKelas, + wilayah: wilayah, + muridList: muridList, + daysInMonth: daysInMonth, + attendanceMap: attendanceMap, + countStatus: countStatus, + ); +} diff --git a/lib/dashboard/d_guru/function/absen_excel/setting_L/pengaturan_absen.dart b/lib/dashboard/d_guru/function/absen_excel/setting_L/pengaturan_absen.dart new file mode 100644 index 0000000..104a842 --- /dev/null +++ b/lib/dashboard/d_guru/function/absen_excel/setting_L/pengaturan_absen.dart @@ -0,0 +1,645 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_guru/database/db_absensi.dart'; + +class UpdateAbsenBottomSheet extends StatefulWidget { + final List> muridList; + final int daysInMonth; + final String yearMonth; + final Map absenIdMap; + final List initialSelectedStudentIds; + final List initialSelectedDays; + final VoidCallback onUpdated; + + const UpdateAbsenBottomSheet({ + super.key, + required this.muridList, + required this.daysInMonth, + required this.yearMonth, + required this.absenIdMap, + required this.initialSelectedStudentIds, + required this.initialSelectedDays, + required this.onUpdated, + }); + + @override + State createState() => _UpdateAbsenBottomSheetState(); +} + +class _UpdateAbsenBottomSheetState extends State { + final DbAbsensi _dbAbsensi = DbAbsensi(); + String? _selectedStatusKey = 'alpha'; + bool _isSaving = false; + String? _errorMessage; + + late Set _selectedStudentIds; + late Set _selectedDays; + + final List> _statusOptions = [ + { + 'key': 'alpha', + 'label': 'Alpha', + 'code': 'A', + 'color': const Color(0xFFDC2626), + 'lightColor': const Color(0xFFFEE2E2), + }, + { + 'key': 'libur', + 'label': 'Libur', + 'code': 'L', + 'color': const Color(0xFF4F46E5), + 'lightColor': const Color(0xFFE0E7FF), + }, + { + 'key': 'belum_absen', + 'label': 'Belum Absen', + 'code': '-', + 'color': const Color(0xFF64748B), + 'lightColor': const Color(0xFFF1F5F9), + }, + ]; + + @override + void initState() { + super.initState(); + _selectedStudentIds = Set.from(widget.initialSelectedStudentIds); + _selectedDays = Set.from(widget.initialSelectedDays); + } + + Future _saveChanges() async { + if (_selectedStudentIds.isEmpty) { + setState(() { + _errorMessage = 'Silakan centang minimal 1 siswa.'; + }); + return; + } + if (_selectedDays.isEmpty) { + setState(() { + _errorMessage = 'Silakan centang minimal 1 hari.'; + }); + return; + } + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + final dbStatus = _selectedStatusKey == 'belum_absen' ? null : _selectedStatusKey; + + // Kumpulkan semua idTabel dari database untuk kombinasi yang dipilih + final List idsToUpdate = []; + for (final idM in _selectedStudentIds) { + for (final d in _selectedDays) { + final idTab = widget.absenIdMap['${idM}_$d']; + if (idTab != null) { + idsToUpdate.add(idTab); + } + } + } + + if (idsToUpdate.isEmpty) { + setState(() { + _errorMessage = 'Tidak ditemukan data absensi untuk kombinasi siswa & tanggal terpilih.'; + _isSaving = false; + }); + return; + } + + // Jalankan update secara paralel agar sangat cepat + await Future.wait( + idsToUpdate.map((id) => _dbAbsensi.updateStudentSessionStatus( + idTabel: id, + selectedStatus: dbStatus, + )) + ); + + if (mounted) { + widget.onUpdated(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Absensi ${idsToUpdate.length} record berhasil diupdate.'), + backgroundColor: const Color(0xFF16A34A), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Gagal menyimpan perubahan: $e'; + _isSaving = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + padding: EdgeInsets.fromLTRB(20, 12, 20, MediaQuery.of(context).viewInsets.bottom + 24), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Handle bar + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // Header info + const Text( + 'Pembaruan Massal Absensi', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + Text( + 'Bulan/Tahun: ${widget.yearMonth}', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: Colors.grey[600], + ), + ), + const Divider(height: 24), + + if (_errorMessage != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFFCA5A5)), + ), + child: Text( + _errorMessage!, + style: const TextStyle( + fontFamily: 'Inter', + color: Color(0xFFDC2626), + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 12), + ], + + // Section 1: Pilihan Status + const Text( + '1. Pilih Status Absensi', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 8), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 2.0, + ), + itemCount: _statusOptions.length, + itemBuilder: (context, index) { + final opt = _statusOptions[index]; + final isSelected = _selectedStatusKey == opt['key']; + return InkWell( + onTap: _isSaving + ? null + : () { + setState(() { + _selectedStatusKey = opt['key']; + if (_selectedStatusKey == 'libur') { + _selectedStudentIds = widget.muridList + .map((m) => m['id_tabel']?.toString()) + .whereType() + .toSet(); + } + }); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + color: isSelected ? opt['lightColor'] : Colors.white, + border: Border.all( + color: isSelected ? opt['color'] : const Color(0xFFE2E8F0), + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: opt['color'], + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + opt['code'], + style: const TextStyle( + fontFamily: 'Inter', + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + opt['label'], + style: TextStyle( + fontFamily: 'Inter', + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: const Color(0xFF334155), + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ), + + // Section 2: Pilihan Hari + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '2. Pilih Hari (${_selectedDays.length}/${widget.daysInMonth})', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + Row( + children: [ + TextButton( + onPressed: _isSaving + ? null + : () { + setState(() { + _selectedDays = List.generate(widget.daysInMonth, (i) => i + 1).toSet(); + }); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Semua', style: TextStyle(fontFamily: 'Inter', fontSize: 12, fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _isSaving + ? null + : () { + setState(() { + _selectedDays.clear(); + }); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Kosongkan', style: TextStyle(fontFamily: 'Inter', fontSize: 12, fontWeight: FontWeight.bold, color: Colors.red)), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.start, + children: List.generate(widget.daysInMonth, (index) { + final d = index + 1; + final isSel = _selectedDays.contains(d); + return InkWell( + onTap: _isSaving + ? null + : () { + setState(() { + if (isSel) { + _selectedDays.remove(d); + } else { + _selectedDays.add(d); + } + }); + }, + borderRadius: BorderRadius.circular(20), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: isSel ? const Color(0xFF2563EB) : Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: isSel ? const Color(0xFF2563EB) : const Color(0xFFCBD5E1), + width: 1, + ), + boxShadow: isSel + ? [ + BoxShadow( + color: const Color(0xFF2563EB).withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ] + : null, + ), + alignment: Alignment.center, + child: Text( + '$d', + style: TextStyle( + fontFamily: 'Inter', + color: isSel ? Colors.white : const Color(0xFF334155), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ); + }), + ), + ), + + // Section 3: Pilihan Siswa + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '3. Pilih Siswa (${_selectedStudentIds.length}/${widget.muridList.length})', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + Row( + children: [ + TextButton( + onPressed: _isSaving || _selectedStatusKey == 'libur' + ? null + : () { + setState(() { + _selectedStudentIds = widget.muridList + .map((m) => m['id_tabel']?.toString()) + .whereType() + .toSet(); + }); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Semua', style: TextStyle(fontFamily: 'Inter', fontSize: 12, fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _isSaving || _selectedStatusKey == 'libur' + ? null + : () { + setState(() { + _selectedStudentIds.clear(); + }); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Kosongkan', style: TextStyle(fontFamily: 'Inter', fontSize: 12, fontWeight: FontWeight.bold, color: Colors.red)), + ), + ], + ), + ], + ), + if (_selectedStatusKey == 'libur') ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFBFDBFE)), + ), + child: const Row( + children: [ + Icon(Icons.info_outline, color: Color(0xFF2563EB), size: 18), + SizedBox(width: 8), + Expanded( + child: Text( + 'Hari Libur otomatis berlaku untuk semua siswa.', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF1D4ED8), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 8), + Container( + height: 200, + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Scrollbar( + thumbVisibility: true, + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.muridList.length, + itemBuilder: (context, index) { + final m = widget.muridList[index]; + final idM = m['id_tabel']?.toString(); + final nama = m['nama']?.toString() ?? 'Siswa'; + final nis = m['nis']?.toString() ?? '-'; + final isSel = idM != null && _selectedStudentIds.contains(idM); + + return InkWell( + onTap: _isSaving || idM == null || _selectedStatusKey == 'libur' + ? null + : () { + setState(() { + if (isSel) { + _selectedStudentIds.remove(idM); + } else { + _selectedStudentIds.add(idM); + } + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: Checkbox( + value: isSel, + activeColor: const Color(0xFF2563EB), + onChanged: _isSaving || idM == null || _selectedStatusKey == 'libur' + ? null + : (val) { + setState(() { + if (val == true) { + _selectedStudentIds.add(idM); + } else { + _selectedStudentIds.remove(idM); + } + }); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nama, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + Text( + 'NIS: $nis', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.grey[500], + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + const SizedBox(height: 24), + + // Actions Buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isSaving ? null : () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Batal', + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: _isSaving ? null : _saveChanges, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text( + 'Simpan', + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/absen_excel/storage_helper.dart b/lib/dashboard/d_guru/function/absen_excel/storage_helper.dart new file mode 100644 index 0000000..fca8bc7 --- /dev/null +++ b/lib/dashboard/d_guru/function/absen_excel/storage_helper.dart @@ -0,0 +1,47 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; + +class StorageHelper { + /// Mendapatkan path direktori Download publik di Android atau Documents di iOS/desktop. + static Future getDownloadDirectoryPath() async { + if (Platform.isAndroid) { + const path = '/storage/emulated/0/Download'; + final dir = Directory(path); + if (await dir.exists()) { + return path; + } + // Fallback 1: external storage directory + final externalDir = await getExternalStorageDirectory(); + if (externalDir != null) { + return externalDir.path; + } + // Fallback 2: cache / temporary directory + final tempDir = await getTemporaryDirectory(); + return tempDir.path; + } else { + final appDocDir = await getApplicationDocumentsDirectory(); + return appDocDir.path; + } + } + + /// Mendapatkan path file unik dengan menambahkan suffix (1), (2), dst. jika file sudah ada. + static String getUniqueFilePath(String dirPath, String baseName, String extension) { + final cleanExt = extension.startsWith('.') ? extension.substring(1) : extension; + String targetPath = '$dirPath/$baseName.$cleanExt'; + File file = File(targetPath); + if (!file.existsSync()) { + return targetPath; + } + + int index = 1; + while (true) { + targetPath = '$dirPath/$baseName ($index).$cleanExt'; + file = File(targetPath); + if (!file.existsSync()) { + return targetPath; + } + index++; + } + } +} + diff --git a/lib/dashboard/d_guru/function/absensi_baru/absen_siswa.dart b/lib/dashboard/d_guru/function/absensi_baru/absen_siswa.dart new file mode 100644 index 0000000..fc57ccd --- /dev/null +++ b/lib/dashboard/d_guru/function/absensi_baru/absen_siswa.dart @@ -0,0 +1,758 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:apk/dashboard/d_guru/database/db_absensi.dart'; +import 'package:apk/dashboard/d_guru/function/absen_excel/pdf/absen_baru_pdf.dart'; + +class AbsenSiswaPage extends StatefulWidget { + final String judul; + final String date; + + const AbsenSiswaPage({ + super.key, + required this.judul, + required this.date, + }); + + @override + State createState() => _AbsenSiswaPageState(); +} + +class _AbsenSiswaPageState extends State { + bool _isLoading = true; + bool _isTableMode = false; + bool _isExporting = false; + String? _errorMessage; + List> _students = []; + + // Map id_tabel -> selected status ('hadir', 'izin', 'sakit', 'alpha', or null) + final Map _selectedStatus = {}; + + final List _statusOptions = ['hadir', 'izin', 'sakit', 'alpha']; + final Map _statusLabels = { + 'hadir': 'Hadir', + 'izin': 'Izin', + 'sakit': 'Sakit', + 'alpha': 'Alpha', + + }; + final Map _statusColors = { + 'hadir': const Color(0xFF10B981), + 'izin': const Color(0xFFF59E0B), + 'sakit': const Color(0xFF3B82F6), + 'alpha': const Color(0xFFEF4444), + + }; + final Map _statusIcons = { + 'hadir': Icons.check_circle, + 'izin': Icons.mail_outline, + 'sakit': Icons.local_hospital, + 'alpha': Icons.cancel, + + }; + + @override + void initState() { + super.initState(); + _fetchStudents(); + } + + Future _fetchStudents() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final students = await DbAbsensi().getSessionStudents(widget.judul, widget.date); + if (!mounted) return; + + // Initialize selected statuses from database + for (var s in students) { + final id = s['id_tabel'].toString(); + String? status; + if (s['Null_data']?.toString() == 'Belum Absen') { + status = null; + } else if (s['ket_hadir'] != null && s['ket_hadir'].toString().isNotEmpty) { + status = 'hadir'; + } else if (s['ket_izin'] != null && s['ket_izin'].toString().isNotEmpty) { + status = 'izin'; + } else if (s['ket_sakit'] != null && s['ket_sakit'].toString().isNotEmpty) { + status = 'sakit'; + } else if (s['ket_alpha'] != null && s['ket_alpha'].toString().isNotEmpty) { + status = 'alpha'; + } else if (s['ket_libur'] != null && s['ket_libur'].toString().isNotEmpty) { + status = 'libur'; + } + _selectedStatus[id] = status; + } + + setState(() { + _students = students; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = 'Gagal memuat data siswa: $e'; + _isLoading = false; + }); + } + } + + Future _updateStatus(String idTabel, String? status) async { + setState(() { + _selectedStatus[idTabel] = status; + }); + + try { + await DbAbsensi().updateStudentSessionStatus( + idTabel: idTabel, + selectedStatus: status, + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal menyimpan: $e', style: const TextStyle(fontFamily: 'Inter'))), + ); + } + } + + Future _deleteSession() async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Hapus Sesi', style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold)), + content: const Text('Apakah Anda yakin ingin menghapus sesi absensi ini? Data tidak dapat dikembalikan.', + style: TextStyle(fontFamily: 'Inter')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Batal', style: TextStyle(fontFamily: 'Inter', color: Color(0xFF64748B))), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFEF4444), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Hapus', style: TextStyle(fontFamily: 'Inter', color: Colors.white)), + ), + ], + ), + ); + + if (confirm == true) { + try { + await DbAbsensi().deleteSession(widget.judul, widget.date); + if (!mounted) return; + Navigator.pop(context); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal menghapus: $e', style: const TextStyle(fontFamily: 'Inter'))), + ); + } + } + } + + void _switchToTableMode() { + setState(() { + _isTableMode = true; + }); + } + + Future _exportPdf() async { + setState(() => _isExporting = true); + + try { + // Build student data list for PDF + final List> pdfStudents = []; + for (int i = 0; i < _students.length; i++) { + final s = _students[i]; + final id = s['id_tabel'].toString(); + final status = _selectedStatus[id]; + final nama = s['murid']?['nama']?.toString() ?? 'Tanpa Nama'; + final nis = s['murid']?['nis']; + + String keterangan = '-'; + if (status != null && _statusLabels.containsKey(status)) { + keterangan = _statusLabels[status]!; + } + + pdfStudents.add({ + 'no': i + 1, + 'nis': nis, + 'nama': nama, + 'keterangan': keterangan, + }); + } + + final path = await exportAbsenBaruPdf( + judul: widget.judul, + date: widget.date, + students: pdfStudents, + ); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('PDF berhasil disimpan di: $path', style: const TextStyle(fontFamily: 'Inter')), + duration: const Duration(seconds: 4), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal export PDF: $e', style: const TextStyle(fontFamily: 'Inter'))), + ); + } finally { + if (mounted) setState(() => _isExporting = false); + } + } + + String _getFormattedDate() { + try { + final parsed = DateTime.parse(widget.date); + return DateFormat('dd MMMM yyyy', 'id_ID').format(parsed); + } catch (_) { + return widget.date; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + titleSpacing: 0, + title: Text(_isTableMode ? 'Hasil Absensi' : 'Absen Siswa'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(_errorMessage!, + style: const TextStyle(color: Colors.red, fontFamily: 'Inter')), + ), + ) + : _isTableMode + ? _buildTableMode() + : _buildChecklistMode(), + ); + } + + // ─── CHECKLIST MODE ────────────────────────────────────────────── + Widget _buildChecklistMode() { + return Column( + children: [ + // Header info + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Color(0xFF2563EB), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.judul, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.calendar_today, color: Colors.white70, size: 14), + const SizedBox(width: 6), + Text( + _getFormattedDate(), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Colors.white70, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_students.length} Siswa', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + ), + + // Student list + Expanded( + child: _students.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.people_outline, size: 80, color: Colors.grey[300]), + const SizedBox(height: 16), + Text('Tidak ada siswa', + style: TextStyle(fontFamily: 'Inter', fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey[600])), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: _students.length, + itemBuilder: (context, index) { + final student = _students[index]; + final idTabel = student['id_tabel'].toString(); + final nama = student['murid']?['nama']?.toString() ?? 'Tanpa Nama'; + final nis = student['murid']?['nis']?.toString() ?? '-'; + final currentStatus = _selectedStatus[idTabel]; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Student info + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + '${index + 1}', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF2563EB), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nama, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + 'NIS: $nis', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF94A3B8), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + // Status chips (radio-style) + Wrap( + spacing: 6, + runSpacing: 6, + children: _statusOptions.map((status) { + final isSelected = currentStatus == status; + final color = _statusColors[status]!; + return GestureDetector( + onTap: () { + // Toggle: tap again to unselect + final newStatus = isSelected ? null : status; + _updateStatus(idTabel, newStatus); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? color.withOpacity(0.15) : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? color : const Color(0xFFE2E8F0), + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isSelected ? _statusIcons[status] : Icons.radio_button_unchecked, + size: 16, + color: isSelected ? color : const Color(0xFF94A3B8), + ), + const SizedBox(width: 4), + Text( + _statusLabels[status]!, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? color : const Color(0xFF64748B), + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ), + ), + ); + }, + ), + ), + + // Bottom buttons + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _deleteSession, + icon: const Icon(Icons.delete_outline, color: Color(0xFFEF4444)), + label: const Text('Hapus', style: TextStyle(fontFamily: 'Inter', color: Color(0xFFEF4444))), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFEF4444)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: _switchToTableMode, + icon: const Icon(Icons.check, color: Colors.white), + label: const Text('Selesai', + style: TextStyle(fontFamily: 'Inter', color: Colors.white, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + ), + ), + ), + ], + ), + ), + ], + ); + } + + // ─── TABLE MODE ────────────────────────────────────────────────── + Widget _buildTableMode() { + return Column( + children: [ + // Header + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.judul, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.calendar_today, size: 14, color: Color(0xFF2563EB)), + const SizedBox(width: 6), + Text( + _getFormattedDate(), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: Color(0xFF2563EB), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ), + + // Summary row + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: _statusOptions.map((status) { + final count = _selectedStatus.values.where((v) => v == status).length; + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: _statusColors[status]!.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _statusColors[status]!.withOpacity(0.3)), + ), + child: Column( + children: [ + Text( + '$count', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.bold, + color: _statusColors[status], + ), + ), + Text( + _statusLabels[status]!, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + color: _statusColors[status], + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ), + + // Table + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: DataTable( + headingRowColor: WidgetStateProperty.all(const Color(0xFF2563EB)), + headingTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + dataTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: Color(0xFF334155), + ), + columnSpacing: 16, + columns: const [ + DataColumn(label: Text('No')), + DataColumn(label: Text('Nama')), + DataColumn(label: Text('Keterangan')), + ], + rows: List.generate(_students.length, (index) { + final s = _students[index]; + final id = s['id_tabel'].toString(); + final nama = s['murid']?['nama']?.toString() ?? 'Tanpa Nama'; + final status = _selectedStatus[id]; + final label = status != null ? _statusLabels[status]! : '-'; + final color = status != null ? _statusColors[status]! : const Color(0xFF94A3B8); + + return DataRow( + color: WidgetStateProperty.resolveWith( + (states) => index.isEven ? const Color(0xFFF8FAFC) : Colors.white, + ), + cells: [ + DataCell(Text('${index + 1}')), + DataCell(Text(nama, maxLines: 1, overflow: TextOverflow.ellipsis)), + DataCell( + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ), + ], + ); + }), + ), + ), + ), + ), + ), + + // Bottom buttons (Back to checklist + Download PDF) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => setState(() => _isTableMode = false), + icon: const Icon(Icons.edit, color: Color(0xFF2563EB), size: 18), + label: const Text('Edit', style: TextStyle(fontFamily: 'Inter', color: Color(0xFF2563EB))), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFF2563EB)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: _isExporting ? null : _exportPdf, + icon: _isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.picture_as_pdf, color: Colors.white), + label: Text( + _isExporting ? 'Mengunduh...' : 'Download PDF', + style: const TextStyle(fontFamily: 'Inter', color: Colors.white, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/dashboard/d_guru/function/absensi_baru/daftar_absen.dart b/lib/dashboard/d_guru/function/absensi_baru/daftar_absen.dart new file mode 100644 index 0000000..798b967 --- /dev/null +++ b/lib/dashboard/d_guru/function/absensi_baru/daftar_absen.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:apk/dashboard/d_guru/database/db_absensi.dart'; +import 'package:apk/dashboard/d_guru/function/absensi_baru/absen_siswa.dart'; + +class DaftarAbsenSesiPage extends StatefulWidget { + const DaftarAbsenSesiPage({super.key}); + + @override + State createState() => _DaftarAbsenSesiPageState(); +} + +class _DaftarAbsenSesiPageState extends State { + bool _isLoading = true; + String? _errorMessage; + List> _sessions = []; + + @override + void initState() { + super.initState(); + _fetchSessions(); + } + + Future _fetchSessions() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final sessions = await DbAbsensi().getCustomSessions(); + if (!mounted) return; + setState(() { + _sessions = sessions; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = 'Gagal memuat daftar absensi: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + titleSpacing: 0, + title: const Text('Daftar Absensi'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + onPressed: _fetchSessions, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontFamily: 'Inter')), + ), + ) + : _sessions.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.list_alt, size: 80, color: Colors.grey[300]), + const SizedBox(height: 16), + Text( + 'Belum Ada Sesi', + style: TextStyle(fontFamily: 'Inter', fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + 'Anda belum membuat sesi absensi baru.', + style: TextStyle(fontFamily: 'Inter', fontSize: 14, color: Colors.grey[500]), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _sessions.length, + itemBuilder: (context, index) { + final session = _sessions[index]; + final judul = session['judul'] ?? 'Tanpa Judul'; + final dateStr = session['date'] ?? ''; + String formattedDate = dateStr; + try { + final parsedDate = DateTime.parse(dateStr); + formattedDate = DateFormat('dd MMMM yyyy', 'id_ID').format(parsedDate); + } catch (_) {} + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shadowColor: Colors.black.withOpacity(0.05), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AbsenSiswaPage( + judul: judul, + date: dateStr, + ), + ), + ).then((_) => _fetchSessions()); // Refresh after back + }, + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.assignment, color: Color(0xFF2563EB)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + judul, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + Text( + formattedDate, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: Color(0xFF64748B), + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF94A3B8)), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/absensi_baru/form_daftar_absen.dart b/lib/dashboard/d_guru/function/absensi_baru/form_daftar_absen.dart new file mode 100644 index 0000000..b34360d --- /dev/null +++ b/lib/dashboard/d_guru/function/absensi_baru/form_daftar_absen.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:apk/dashboard/d_guru/database/db_absensi.dart'; +import 'package:apk/dashboard/d_guru/function/absensi_baru/daftar_absen.dart' as absen_baru; + +class FormDaftarAbsenPage extends StatefulWidget { + const FormDaftarAbsenPage({super.key}); + + @override + State createState() => _FormDaftarAbsenPageState(); +} + +class _FormDaftarAbsenPageState extends State { + final _judulController = TextEditingController(); + DateTime _selectedDate = DateTime.now(); + bool _isLoading = false; + + Future _selectDate(BuildContext context) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2000), + lastDate: DateTime(2101), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF2563EB), + onPrimary: Colors.white, + onSurface: Color(0xFF1E293B), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF2563EB), + ), + ), + ), + child: child!, + ); + }, + ); + if (picked != null && picked != _selectedDate) { + setState(() { + _selectedDate = picked; + }); + } + } + + Future _simpan() async { + final judul = _judulController.text.trim(); + if (judul.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Judul absensi tidak boleh kosong.')), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + await DbAbsensi().createNewSession(judul, _selectedDate); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sesi absensi berhasil dibuat!', style: TextStyle(fontFamily: 'Inter'))), + ); + // Navigate to daftar absen list + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const absen_baru.DaftarAbsenSesiPage()), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e', style: const TextStyle(fontFamily: 'Inter'))), + ); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + void dispose() { + _judulController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + titleSpacing: 0, + title: const Text('Buat Absensi Baru'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Judul Absensi", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF334155), + ), + ), + const SizedBox(height: 8), + TextField( + controller: _judulController, + decoration: InputDecoration( + hintText: "Misal: Ujian Tengah Semester", + hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontFamily: 'Inter'), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF2563EB)), + ), + ), + style: const TextStyle(fontFamily: 'Inter'), + ), + const SizedBox(height: 24), + const Text( + "Tanggal", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF334155), + ), + ), + const SizedBox(height: 8), + InkWell( + onTap: () => _selectDate(context), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + DateFormat('dd MMMM yyyy', 'id_ID').format(_selectedDate), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + color: Color(0xFF1E293B), + ), + ), + const Icon(Icons.calendar_today, color: Color(0xFF64748B), size: 20), + ], + ), + ), + ), + const SizedBox(height: 48), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _simpan, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: const Text( + "Simpan", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/d_guru/daftar_guru.dart b/lib/dashboard/d_guru/function/d_guru/daftar_guru.dart new file mode 100644 index 0000000..2b4b299 --- /dev/null +++ b/lib/dashboard/d_guru/function/d_guru/daftar_guru.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; + +class DetailGuruPage extends StatefulWidget { + final Map data; + + const DetailGuruPage({ + super.key, + required this.data, + }); + + @override + State createState() => _DetailGuruPageState(); +} + +class _DetailGuruPageState extends State { + late String _nik; + late String _nama; + late String _bidang; + late bool isWali; + + @override + void initState() { + super.initState(); + _nik = widget.data['nik']?.toString() ?? "Belum diisi"; + _nama = widget.data['name'] ?? "Belum diisi"; + _bidang = widget.data['bidang'] ?? ""; + isWali = widget.data['wali'] == true; + } + + String get _badge { + final classNameObj = widget.data['class_name']; + String kelas = ""; + if (classNameObj != null) { + if (classNameObj is List && classNameObj.isNotEmpty) { + kelas = classNameObj[0]['name_class']?.toString() ?? ""; + } else if (classNameObj is Map) { + kelas = classNameObj['name_class']?.toString() ?? ""; + } + } + + if (isWali) { + return kelas.isEmpty ? "Wali Kelas" : kelas; + } + return _bidang.isEmpty ? "Guru" : _bidang; + } + + IconData _iconForLabel(String label) { + switch (label) { + case "NIK": + return Icons.badge_outlined; + case "Nama": + return Icons.person_outline; + case "Bidang": + return Icons.menu_book_outlined; + default: + return Icons.info_outline; + } + } + + Widget _infoCard({ + required IconData icon, + required String title, + required String value, + bool isEmpty = false, + }) { + return CustomCard( + title: title, + subtitle: value, + icon: icon, + iconPosition: IconPosition.left, + iconColor: const Color(0xFF2563EB), + iconSize: 24, + iconContainerSize: 46, + iconDecoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(9), + ), + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + borderRadius: 0, + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + boxShadow: const [], + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF8FA0BA), + ), + subtitleStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + fontStyle: isEmpty ? FontStyle.italic : FontStyle.normal, + color: isEmpty ? const Color(0xFFC8D2E0) : const Color(0xFF172033), + ), + showIcon: true, + ); + } + + Widget _sectionCard(List children) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8EEF6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) + const Divider( + height: 1, + thickness: 1, + color: Color(0xFFF0F4FA), + indent: 82, + endIndent: 20, + ), + ], + ], + ), + ), + ); + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 12), + child: Text( + title, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w800, + color: Color(0xFF64748B), + ), + ), + ); + } + + Widget _profileHeader() { + return SizedBox( + height: 128, + child: Stack( + alignment: Alignment.center, + children: [ + CustomCard( + title: "", + showIcon: false, + height: 126, + backgroundColor: Colors.white, + borderColor: const Color(0xFFE8EEF6), + borderWidth: 1, + borderRadius: 18, + margin: EdgeInsets.zero, + padding: EdgeInsets.zero, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.13), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _nama, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 23, + fontWeight: FontWeight.w800, + color: Color(0xFF172033), + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.badge_outlined, + size: 18, + color: Color(0xFF8FA0BA), + ), + const SizedBox(width: 6), + Text( + "NIK: $_nik", + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ], + ), + const SizedBox(height: 18), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.school_outlined, + size: 18, + color: Color(0xFF2563EB), + ), + const SizedBox(width: 6), + Text( + _badge, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF2563EB), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget field(String label, String value) { + return _infoCard( + icon: _iconForLabel(label), + title: label, + value: value.isEmpty ? "Belum diisi" : value, + isEmpty: value.isEmpty, + ); + } + + Widget kelasField() { + final classNameObj = widget.data['class_name']; + String kelas = "Belum diisi"; + if (classNameObj != null) { + if (classNameObj is List && classNameObj.isNotEmpty) { + kelas = classNameObj[0]['name_class']?.toString() ?? "Belum diisi"; + } else if (classNameObj is Map) { + kelas = classNameObj['name_class']?.toString() ?? "Belum diisi"; + } + } + + return _infoCard( + icon: Icons.school_outlined, + title: "Kelas", + value: kelas.isEmpty ? "Belum diisi" : kelas, + isEmpty: kelas.isEmpty, + ); + } + + Widget waliField() { + return _infoCard( + icon: Icons.verified_user_outlined, + title: "Wali Kelas", + value: isWali ? "Ya" : "Tidak", + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + title: const Text("Detail Guru", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600)), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1E293B), + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), + children: [ + _profileHeader(), + const SizedBox(height: 20), + _sectionTitle("Identitas"), + _sectionCard([ + field("Bidang", _bidang), + waliField(), + if (isWali || widget.data['id_class'] != null) kelasField(), + ]), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/detail_murid/detail_murid.dart b/lib/dashboard/d_guru/function/detail_murid/detail_murid.dart new file mode 100644 index 0000000..6188051 --- /dev/null +++ b/lib/dashboard/d_guru/function/detail_murid/detail_murid.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DetailMuridPage extends StatefulWidget { + final Map data; + + const DetailMuridPage({ + super.key, + required this.data, + }); + + @override + State createState() => _DetailMuridPageState(); +} + +class _DetailMuridPageState extends State { + bool _isLoading = true; + Map _fullData = {}; + + @override + void initState() { + super.initState(); + _fullData = Map.from(widget.data); + _fetchFullData(); + } + + Future _fetchFullData() async { + try { + final idTabel = widget.data['id_tabel']?.toString(); + if (idTabel == null) return; + + final response = await Supabase.instance.client + .from('murid') + .select('*, class_name(name_class)') + .eq('id_tabel', idTabel) + .maybeSingle(); + + if (response != null && mounted) { + setState(() { + _fullData = Map.from(response); + _isLoading = false; + }); + } else { + if (mounted) setState(() => _isLoading = false); + } + } catch (e) { + if (mounted) setState(() => _isLoading = false); + } + } + + String _formatTanggalLahir(dynamic value) { + if (value == null) return '-'; + try { + final date = DateTime.parse(value.toString()); + return DateFormat('dd MMMM yyyy', 'id_ID').format(date); + } catch (_) { + return value.toString(); + } + } + + @override + Widget build(BuildContext context) { + final nama = _fullData['nama']?.toString() ?? '-'; + final nis = _fullData['nis']?.toString() ?? '-'; + final gender = _fullData['gender']?.toString() ?? '-'; + final tanggalLahir = _formatTanggalLahir(_fullData['tanggal_lahir']); + final alamat = _fullData['alamat']?.toString() ?? '-'; + final orangTua = _fullData['orang_tua']?.toString() ?? '-'; + final noTele = _fullData['no_tele']?.toString() ?? '-'; + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + body: CustomScrollView( + slivers: [ + // ─── App Bar / Header ─────────────────────────────────────────── + SliverAppBar( + expandedHeight: 150, + pinned: true, + backgroundColor: const Color(0xFF2563EB), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Detail Siswa', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF1D4ED8), Color(0xFF3B82F6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: _isLoading + ? const Center( + child: CircularProgressIndicator(color: Colors.white), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 48), + Text( + nama, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + _badgeWidget('NIS: $nis'), + ], + ), + ), + ), + ), + + // ─── Content ───────────────────────────────────────────────────── + SliverToBoxAdapter( + child: _isLoading + ? const SizedBox() + : Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Identitas Siswa'), + const SizedBox(height: 8), + _infoCard([ + _infoRow(Icons.wc_rounded, 'Jenis Kelamin', gender), + _divider(), + _infoRow(Icons.cake_rounded, 'Tanggal Lahir', tanggalLahir), + _divider(), + _infoRow(Icons.location_on_rounded, 'Alamat', alamat), + ]), + const SizedBox(height: 20), + _sectionTitle('Kontak Orang Tua'), + const SizedBox(height: 8), + _infoCard([ + _infoRow(Icons.person_rounded, 'Nama Orang Tua', orangTua), + _divider(), + _infoRow(Icons.phone_rounded, 'No. Telepon', noTele), + ]), + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _badgeWidget(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withOpacity(0.5)), + ), + child: Text( + text, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + Widget _sectionTitle(String title) { + return Text( + title, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF64748B), + letterSpacing: 0.5, + ), + ); + } + + Widget _infoCard(List children) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column(children: children), + ); + } + + Widget _infoRow(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: const Color(0xFF2563EB)), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Color(0xFF94A3B8), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 3), + Text( + value, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFF1E293B), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _divider() => const Divider(height: 1, indent: 66, color: Color(0xFFF1F5F9)); +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/anggota_g/member_kelas_g.dart b/lib/dashboard/d_guru/function/f_pesan_g/anggota_g/member_kelas_g.dart new file mode 100644 index 0000000..8bd4f52 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/anggota_g/member_kelas_g.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_guru/database/db_guru.dart'; +import 'package:apk/dashboard/d_guru/database/db_murid.dart'; + +class MemberKelasPageG extends StatefulWidget { + final String classId; + const MemberKelasPageG({super.key, required this.classId}); + + @override + State createState() => _MemberKelasPageGState(); +} + +class _MemberKelasPageGState extends State { + final DbGuru _dbGuru = DbGuru(); + final DbMurid _dbMurid = DbMurid(); + + bool _isLoading = true; + String? _errorMessage; + + List> _gurusInClass = []; + List> _muridsInClass = []; + String _className = 'Kelas'; + + @override + void initState() { + super.initState(); + _fetchMembers(); + } + + Future _fetchMembers() async { + try { + final guruList = await _dbGuru.getAllGuru(); + final muridList = await _dbMurid.getAllMurid(); + + final List> gurus = []; + final List> murids = []; + + // Ambil nama kelas untuk judul + final supabase = Supabase.instance.client; + final classInfo = await supabase.from('class_name').select('name_class').eq('id_tabel', widget.classId).maybeSingle(); + if (classInfo?['name_class'] != null) { + _className = classInfo!['name_class'].toString(); + } + + // Process Guru (Cek id_class dan boolean 'wali') + for (var guru in guruList) { + if (guru['id_class']?.toString() == widget.classId) { + if (guru['wali'] == true) { + guru['role'] = 'Guru / Wali Kelas'; + } else { + guru['role'] = 'Guru'; + } + gurus.add(guru); + } + } + + // Process Murid + for (var murid in muridList) { + if (murid['id_class']?.toString() == widget.classId) { + murid['role'] = 'Murid'; + murids.add(murid); + } + } + + setState(() { + _gurusInClass = gurus; + _muridsInClass = murids; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Gagal memuat anggota kelas: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + titleSpacing: 0, + title: Text('Anggota $_className'), + backgroundColor: const Color(0xFF2563EB), + iconTheme: const IconThemeData(color: Colors.white), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontFamily: 'Inter')), + ), + ) + : _buildMemberList(), + ); + } + + Widget _buildMemberList() { + if (_gurusInClass.isEmpty && _muridsInClass.isEmpty) { + return const Center(child: Text('Belum ada anggota di kelas ini.', style: TextStyle(fontFamily: 'Inter'))); + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (_gurusInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Guru:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ..._gurusInClass.map((g) { + final name = g['name'] ?? '-'; + + return ListTile( + leading: const CircleAvatar( + backgroundColor: Color(0xFF2563EB), + child: Icon(Icons.person, color: Colors.white), + ), + title: Text(name, style: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w500)), + subtitle: Text(g['role'] ?? 'Guru', style: const TextStyle(fontFamily: 'Inter', fontSize: 13)), + ); + }).toList(), + + if (_muridsInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Murid:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ..._muridsInClass.map((m) { + final name = m['nama'] ?? '-'; + + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.orange[400], + child: const Icon(Icons.person_outline, color: Colors.white), + ), + title: Text(name, style: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w500)), + subtitle: const Text('Murid', style: const TextStyle(fontFamily: 'Inter', fontSize: 13)), + ); + }).toList(), + ], + ); + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/anggota_g/member_sekolah_g.dart b/lib/dashboard/d_guru/function/f_pesan_g/anggota_g/member_sekolah_g.dart new file mode 100644 index 0000000..bb5f228 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/anggota_g/member_sekolah_g.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_guru/database/db_guru.dart'; +import 'package:apk/dashboard/d_guru/database/db_murid.dart'; + +class MemberSekolah extends StatefulWidget { + const MemberSekolah({super.key}); + + @override + State createState() => _MemberSekolahState(); +} + +class _MemberSekolahState extends State { + bool _isLoading = true; + List> _listClass = []; + List> _listGuru = []; + List> _listMurid = []; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + try { + final classRes = await DbMurid().getKelas(); + final guruRes = await DbGuru().getAllGuru(); + final muridRes = await DbMurid().getAllMurid(); + + if (mounted) { + setState(() { + _listClass = classRes; + _listGuru = guruRes; + _listMurid = muridRes; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Anggota Grup Sekolah'), + backgroundColor: const Color(0xFF2563EB), + iconTheme: const IconThemeData(color: Colors.white), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _buildMemberList(), + ); + } + + Widget _buildMemberList() { + // Kita bisa mengelompokkan berdasarkan id_class + // Tambahkan 1 kelas "Tanpa Kelas" jika ada yang id_class nya null + List> allClasses = List.from(_listClass); + allClasses.add({ + 'id_tabel': null, + 'name_class': 'Tanpa Kelas (Belum Dimasukkan Kelas)' + }); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: allClasses.length, + itemBuilder: (context, index) { + final currentClass = allClasses[index]; + final classId = currentClass['id_tabel']; + final className = currentClass['name_class']; + + // Filter Guru dan Murid untuk kelas ini + final gurusInClass = _listGuru + .where((g) => g['id_class'] == classId) + .toList(); + final muridsInClass = _listMurid + .where((m) => m['id_class'] == classId) + .toList(); + + if (gurusInClass.isEmpty && muridsInClass.isEmpty && classId == null) { + return const SizedBox.shrink(); // Jangan tampilkan 'Tanpa Kelas' jika kosong + } + + return ExpansionTile( + title: Text( + className, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + children: [ + if (gurusInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Guru:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ...gurusInClass.map((g) { + final isWali = g['wali'] == true; + return ListTile( + leading: const CircleAvatar( + backgroundColor: Color(0xFF2563EB), + child: Icon(Icons.person, color: Colors.white), + ), + title: Text(g['name'] ?? '-'), + subtitle: Text(isWali ? 'Guru / Wali Kelas' : 'Guru'), + ); + }).toList(), + + if (muridsInClass.isNotEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Daftar Murid:', + style: TextStyle( + fontFamily: 'Inter', + color: Color(0xFF2563EB), + fontWeight: FontWeight.w600), + ), + ), + ), + ...muridsInClass.map((m) { + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.orange[400], + child: const Icon(Icons.person_outline, color: Colors.white), + ), + title: Text(m['nama'] ?? '-'), + subtitle: const Text('Murid'), + ); + }).toList(), + if (gurusInClass.isEmpty && muridsInClass.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text('Belum ada anggota di kelas ini.'), + ) + ], + ); + }, + ); + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/chat_admin_g.dart b/lib/dashboard/d_guru/function/f_pesan_g/chat_admin_g.dart new file mode 100644 index 0000000..9998407 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/chat_admin_g.dart @@ -0,0 +1,462 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_guru/database/db_pesan_private.dart'; +import 'package:intl/intl.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/d_guru/database/storage/image_doc.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/doc_image.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/chat_private_n.dart'; + +class ChatAdminPageG extends StatefulWidget { + const ChatAdminPageG({super.key}); + + @override + State createState() => _ChatAdminPageGState(); +} + +class _ChatAdminPageGState extends State { + final TextEditingController _messageController = TextEditingController(); + final DbPesanPrivate _dbPesan = DbPesanPrivate(); + + bool _isLoading = true; + String? _errorMessage; + String? _idAdmin; + String? _idGuru; + String? _userIdAdmin; + + Stream>>? _chatStream; + + @override + void initState() { + super.initState(); + _fetchIdentities(); + } + + Future _fetchIdentities() async { + try { + final prefs = await SharedPreferences.getInstance(); + final nik = prefs.getString('guru_nik'); + _userIdAdmin = prefs.getString('user_id_admin'); + + if (nik == null || _userIdAdmin == null) { + throw Exception('Data login tidak lengkap (NIK atau User ID kosong)'); + } + + // 1. Dapatkan id_guru (id_tabel) + final guruRes = await Supabase.instance.client + .from('guru') + .select('id_tabel') + .eq('user_id', _userIdAdmin!) + .eq('nik', nik) + .single(); + _idGuru = guruRes['id_tabel']?.toString(); + + // 2. Dapatkan id_admin dari user_admin. Kolom `id` di tabel user_admin + // adalah foreign key yang terhubung langsung ke auth.users(id). + final adminRes = await Supabase.instance.client + .from('user_admin') + .select('id') + .eq('id', _userIdAdmin!) + .single(); + _idAdmin = adminRes['id']?.toString(); + + if (_idGuru != null && _idAdmin != null) { + _chatStream = _dbPesan.getChatWithGuruStream(_idAdmin!, _idGuru!); + ChatPrivateNotification.setSenderUnread(_idAdmin!, false); + } + } catch (e) { + _errorMessage = 'Error fetching identities: $e'; + debugPrint(_errorMessage); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + final currentUserId = Supabase.instance.client.auth.currentUser?.id ?? _userIdAdmin; + final data = { + 'text': 'IMAGE_URL:$url', + 'pengirim_guru': _idGuru, + 'penerima_admin': _idAdmin, + 'user_id': currentUserId, + }; + await _dbPesan.sendMessage(data); + _fetchIdentities(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + final currentUserId = Supabase.instance.client.auth.currentUser?.id ?? _userIdAdmin; + final data = { + 'text': 'DOC_URL:$url', + 'pengirim_guru': _idGuru, + 'penerima_admin': _idAdmin, + 'user_id': currentUserId, + }; + await _dbPesan.sendMessage(data); + _fetchIdentities(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + Future _sendMessage() async { + if (_messageController.text.trim().isEmpty) return; + if (_idGuru == null || _idAdmin == null || _userIdAdmin == null) return; + + final text = _messageController.text.trim(); + _messageController.clear(); + + final currentUserId = Supabase.instance.client.auth.currentUser?.id ?? _userIdAdmin; + + final data = { + 'text': text, + 'pengirim_guru': _idGuru, + 'penerima_admin': _idAdmin, + 'user_id': currentUserId, + }; + + try { + await _dbPesan.sendMessage(data); + + // Reload pembacaan database setelah mengirim pesan + if (mounted) { + setState(() { + _chatStream = _dbPesan.getChatWithGuruStream(_idAdmin!, _idGuru!); + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF1F5F9), + appBar: AppBar( + titleSpacing: 0, + title: const Text('Chat Admin Sekolah'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center(child: Padding(padding: const EdgeInsets.all(16), child: Text(_errorMessage!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.red)))) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan (stream null)')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center( + child: Text( + 'Belum ada pesan.\nKirim pesan pertama Anda!', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, fontFamily: 'Inter'), + ), + ); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + final isMe = msg['pengirim_guru'] == _idGuru; + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + _buildMessageInput(), + ], + ), + ); + } + + Widget _buildMessageInput() { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -120), + onSelected: (value) { + if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + ], + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/chat_private_g.dart b/lib/dashboard/d_guru/function/f_pesan_g/chat_private_g.dart new file mode 100644 index 0000000..60937f7 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/chat_private_g.dart @@ -0,0 +1,927 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_guru/database/db_guru.dart'; +import 'package:intl/intl.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/d_guru/database/storage/image_doc.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/doc_image.dart'; +import 'package:apk/dashboard/d_guru/database/db_grup_kelas.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/chat_private_n.dart'; + +class ChatPrivateG extends StatefulWidget { + final String receiverId; + final String receiverType; // 'guru' or 'murid' + final String receiverName; + + const ChatPrivateG({ + super.key, + required this.receiverId, + required this.receiverType, + required this.receiverName, + }); + + @override + State createState() => _ChatPrivateGState(); +} + +class _ChatPrivateGState extends State { + final TextEditingController _messageController = TextEditingController(); + final supabase = Supabase.instance.client; + + bool _isLoading = true; + String? _errorMessage; + String? _currentGuruId; + + Stream>>? _chatStream; + + @override + void initState() { + super.initState(); + _initChat(); + ChatPrivateNotification.setSenderUnread(widget.receiverId, false); + } + + Future _initChat() async { + try { + final guruInfo = await DbGuru().getLoggedInGuruInfo(); + _currentGuruId = guruInfo['id_tabel'].toString(); + + if (_currentGuruId != null) { + if (widget.receiverType == 'guru') { + _chatStream = supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_guru']?.toString() == _currentGuruId && e['penerima_guru']?.toString() == widget.receiverId) || + (e['pengirim_guru']?.toString() == widget.receiverId && e['penerima_guru']?.toString() == _currentGuruId)) + .map((e) => e as Map) + .toList()); + } else if (widget.receiverType == 'murid') { + _chatStream = supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: true) + .map((data) => data + .where((e) => + (e['pengirim_guru']?.toString() == _currentGuruId && e['penerima_murid']?.toString() == widget.receiverId) || + (e['pengirim_murid']?.toString() == widget.receiverId && e['penerima_guru']?.toString() == _currentGuruId)) + .map((e) => e as Map) + .toList()); + } + } else { + _errorMessage = 'Gagal memuat profil Guru.'; + } + } catch (e) { + _errorMessage = 'Error: $e'; + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + final userId = supabase.auth.currentUser?.id; + if (widget.receiverType == 'guru') { + await supabase.from('chat_private').insert({ + 'text': 'IMAGE_URL:$url', + 'pengirim_guru': _currentGuruId, + 'penerima_guru': widget.receiverId, + 'user_id': userId, + }); + } else if (widget.receiverType == 'murid') { + await supabase.from('chat_private').insert({ + 'text': 'IMAGE_URL:$url', + 'pengirim_guru': _currentGuruId, + 'penerima_murid': widget.receiverId, + 'user_id': userId, + }); + } + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + final userId = supabase.auth.currentUser?.id; + if (widget.receiverType == 'guru') { + await supabase.from('chat_private').insert({ + 'text': 'DOC_URL:$url', + 'pengirim_guru': _currentGuruId, + 'penerima_guru': widget.receiverId, + 'user_id': userId, + }); + } else if (widget.receiverType == 'murid') { + await supabase.from('chat_private').insert({ + 'text': 'DOC_URL:$url', + 'pengirim_guru': _currentGuruId, + 'penerima_murid': widget.receiverId, + 'user_id': userId, + }); + } + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _currentGuruId == null) return; + + _messageController.clear(); + + try { + final userId = supabase.auth.currentUser?.id; + + if (widget.receiverType == 'guru') { + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_guru': _currentGuruId, + 'penerima_guru': widget.receiverId, + 'user_id': userId, + }); + } else if (widget.receiverType == 'murid') { + await supabase.from('chat_private').insert({ + 'text': text, + 'pengirim_guru': _currentGuruId, + 'penerima_murid': widget.receiverId, + 'user_id': userId, + }); + } + + if (mounted) { + setState(() { + _isLoading = true; + }); + await _initChat(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Chat: ${widget.receiverName}'), + backgroundColor: const Color(0xFF2563EB), + iconTheme: const IconThemeData(color: Colors.white), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center(child: Padding(padding: const EdgeInsets.all(16), child: Text(_errorMessage!, style: const TextStyle(color: Colors.red)))) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center(child: Text('Belum ada pesan')); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + final isMe = msg['pengirim_guru']?.toString() == _currentGuruId; + final textMsg = msg['text']?.toString() ?? ''; + if (textMsg.startsWith("📋 *Pengajuan Izin*")) { + return _buildIzinCard(textMsg, widget.receiverName, _formatTime(msg['created_at']), isMe); + } + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: Row( + children: [ + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -120), + onSelected: (value) { + if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + ], + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildIzinCard(String text, String senderName, String time, bool isMe) { + final lines = text.split('\n'); + String nama = ''; + String tanggal = ''; + String keterangan = ''; + List lampiranUrls = []; + String idIzin = ''; + String jenis = ''; + + for (var rawLine in lines) { + final line = rawLine.trim(); + if (line.startsWith('Nama: ')) nama = line.substring(6).trim(); + else if (line.startsWith('Tanggal: ')) tanggal = line.substring(9).trim(); + else if (line.startsWith('Keterangan: ')) keterangan = line.substring(12).trim(); + else if (line.startsWith('Lampiran: ')) { + final urlsString = line.substring(10).trim(); + if (urlsString.isNotEmpty) { + lampiranUrls = urlsString.split(RegExp(r'\s+')).where((s) => s.startsWith('http')).toList(); + } + } + else if (line.startsWith('ID_IZIN: ')) idIzin = line.substring(8).trim(); + else if (line.startsWith('Jenis: ')) jenis = line.substring(7).trim(); + else if (keterangan.isNotEmpty && lampiranUrls.isEmpty && !line.startsWith('📋') && !line.startsWith('ID_IZIN:') && !line.startsWith('Jenis:')) { + keterangan += '\n$line'; + } + } + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.85, + ), + child: Card( + color: Colors.amber[50], + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + jenis.toLowerCase() == 'sakit' ? Icons.sick : Icons.assignment_ind, + color: jenis.toLowerCase() == 'sakit' ? Colors.red : Colors.amber, + size: 28, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + jenis.isNotEmpty ? 'Pengajuan $jenis' : 'Pengajuan Izin', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ], + ), + const Divider(), + Row( + children: [ + const Icon(Icons.person, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + nama, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black87), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.date_range, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + tanggal, + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ], + ), + if (keterangan.isNotEmpty) ...[ + const SizedBox(height: 8), + const Text('Keterangan:', style: TextStyle(fontSize: 12, color: Colors.grey)), + Text(keterangan, style: const TextStyle(fontSize: 14)), + ], + if (lampiranUrls.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: lampiranUrls.map((urlStr) { + final uri = Uri.tryParse(urlStr); + String extension = 'file'; + String cleanName = 'Lampiran'; + if (uri != null && uri.pathSegments.isNotEmpty) { + final fullName = Uri.decodeComponent(uri.pathSegments.last); + cleanName = fullName.replaceFirst(RegExp(r'^\d{13}_(?:camera_)?'), ''); + final extIdx = fullName.lastIndexOf('.'); + if (extIdx != -1 && extIdx < fullName.length - 1) { + extension = fullName.substring(extIdx + 1).toLowerCase(); + } + } + final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); + final extLabel = extension.length > 4 + ? extension.substring(0, 4).toUpperCase() + : extension.toUpperCase(); + + if (isImage) { + return GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute( + builder: (_) => DocImagePreviewPage(imageUrl: urlStr), + )); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + urlStr, + width: 110, + height: 110, + fit: BoxFit.cover, + loadingBuilder: (ctx, child, progress) => + progress == null ? child : const SizedBox(width: 110, height: 110, child: Center(child: CircularProgressIndicator(strokeWidth: 2))), + errorBuilder: (ctx, _, __) => const SizedBox(width: 110, height: 110, child: Icon(Icons.broken_image)), + ), + ), + ); + } else { + return GestureDetector( + onTap: () async { + if (uri != null) { + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tidak dapat membuka lampiran')), + ); + } + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 220), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.85), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.black12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extLabel, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cleanName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + '$extLabel • Dokumen', + style: const TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ), + ), + ], + ), + ), + ); + } + }).toList(), + ), + ], + if (idIzin.isNotEmpty) ...[ + const Divider(), + IzinStatusWidget(idIzin: idIzin, isTeacher: true, textPesan: text), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.bottomRight, + child: Text( + time, + style: const TextStyle(fontSize: 10, color: Colors.black54), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class IzinStatusWidget extends StatefulWidget { + final String idIzin; + final bool isTeacher; + final String? textPesan; + const IzinStatusWidget({super.key, required this.idIzin, required this.isTeacher, this.textPesan}); + + @override + State createState() => _IzinStatusWidgetState(); +} + +class _IzinStatusWidgetState extends State { + bool? _verifStatus; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchStatus(); + } + + Future _fetchStatus() async { + if (widget.idIzin.isEmpty) { + setState(() => _isLoading = false); + return; + } + try { + final res = await Supabase.instance.client + .from('leave_request') + .select('verif') + .eq('id_tabel', widget.idIzin) + .maybeSingle(); + if (res != null && mounted) { + setState(() { + _verifStatus = res['verif']; + _isLoading = false; + }); + } else { + setState(() => _isLoading = false); + } + } catch (e) { + setState(() => _isLoading = false); + } + } + + Future _updateStatus(bool status) async { + setState(() => _isLoading = true); + try { + // 1. Fetch current status first to prevent duplicate posting + final currentRes = await Supabase.instance.client + .from('leave_request') + .select('verif, id_murid, id_kelas') + .eq('id_tabel', widget.idIzin) + .maybeSingle(); + + if (currentRes == null) { + throw Exception("Data pengajuan tidak ditemukan"); + } + + final bool? currentVerif = currentRes['verif']; + + // 2. Update status in database + await Supabase.instance.client + .from('leave_request') + .update({'verif': status}) + .eq('id_tabel', widget.idIzin); + + // 3. If transitioning to approved (verif was not true, now is true), send to group chat + if (status == true && currentVerif != true) { + final idMurid = currentRes['id_murid']?.toString(); + final idKelas = currentRes['id_kelas']?.toString(); + if (idMurid != null && idKelas != null && widget.textPesan != null && widget.textPesan!.isNotEmpty) { + try { + await DbGrupKelas().sendMessage( + text: widget.textPesan!, + senderId: idMurid, + role: 'murid', + idClass: idKelas, + ); + } catch (e) { + debugPrint("Gagal meneruskan izin ke grup kelas: $e"); + } + } + } + + if (mounted) { + setState(() { + _verifStatus = status; + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(status ? 'Pengajuan izin disetujui!' : 'Pengajuan izin ditolak!'), + backgroundColor: status ? Colors.green : Colors.red, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal memperbarui status: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + if (_verifStatus == true) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: Color(0xFF059669), size: 16), + SizedBox(width: 6), + Text( + 'Disetujui', + style: TextStyle(color: Color(0xFF059669), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } + + if (_verifStatus == false) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cancel, color: Color(0xFFDC2626), size: 16), + SizedBox(width: 6), + Text( + 'Ditolak', + style: TextStyle(color: Color(0xFFDC2626), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } + + // Status: Pending (null) + if (widget.isTeacher) { + return Container( + margin: const EdgeInsets.only(top: 12), + child: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _updateStatus(true), + icon: const Icon(Icons.check, size: 16, color: Colors.white), + label: const Text('Setujui', style: TextStyle(color: Colors.white, fontFamily: 'Inter', fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF059669), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: () => _updateStatus(false), + icon: const Icon(Icons.close, size: 16, color: Color(0xFFDC2626)), + label: const Text('Tolak', style: TextStyle(color: Color(0xFFDC2626), fontFamily: 'Inter', fontWeight: FontWeight.bold)), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFDC2626)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ), + ); + } + + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.hourglass_empty, color: Color(0xFFD97706), size: 16), + SizedBox(width: 6), + Text( + 'Menunggu Persetujuan', + style: TextStyle(color: Color(0xFFD97706), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/doc_image.dart b/lib/dashboard/d_guru/function/f_pesan_g/doc_image.dart new file mode 100644 index 0000000..8ed03a3 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/doc_image.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class DocImagePreviewPage extends StatelessWidget { + final String imageUrl; + + const DocImagePreviewPage({super.key, required this.imageUrl}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.white), + ), + body: Center( + child: InteractiveViewer( + child: Image.network( + imageUrl, + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center(child: CircularProgressIndicator(color: Colors.white)); + }, + errorBuilder: (context, error, stackTrace) => const Icon(Icons.broken_image, color: Colors.white, size: 50), + ), + ), + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/f_notif/chat_private_n.dart b/lib/dashboard/d_guru/function/f_pesan_g/f_notif/chat_private_n.dart new file mode 100644 index 0000000..51c1cec --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/f_notif/chat_private_n.dart @@ -0,0 +1,219 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_guru/database/db_guru.dart'; + + +/// Notification handler for Chat Private (Guru ↔ Ortu/Murid). +/// Must call [ChatPrivateNotification.init()] once after login. +/// Call [ChatPrivateNotification.dispose()] on logout. +class ChatPrivateNotification { + // GlobalKey provided by the root MaterialApp (shared with other notification handlers) + static GlobalKey? navigatorKey; + + static String? _currentUserId; // ID di tabel murid/guru (bukan auth.uid) + static String? _currentUserRole; // 'ortu', 'murid', or 'guru' + + // Supabase stream subscription + static StreamSubscription>>? _streamSubscription; + static DateTime? _lastSeenAt; + + static final ValueNotifier hasUnread = ValueNotifier(false); + static final ValueNotifier> unreadSenderIds = ValueNotifier>({}); + + static Future setSenderUnread(String senderId, bool isUnread) async { + final current = Set.from(unreadSenderIds.value); + if (isUnread) { + current.add(senderId); + } else { + current.remove(senderId); + } + unreadSenderIds.value = current; + hasUnread.value = current.isNotEmpty; + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList('chat_private_unread_senders', current.toList()); + await prefs.setBool('chat_private_has_unread', current.isNotEmpty); + if (!isUnread) { + await prefs.setString('chat_private_last_read_$senderId', DateTime.now().toUtc().toIso8601String()); + } + if (!isUnread) { + await prefs.setString('chat_private_last_read_$senderId', DateTime.now().toUtc().toIso8601String()); + } + } catch (e) { + debugPrint('[ChatPrivateNotification] setSenderUnread error: $e'); + } + } + + static Future setUnread(bool value) async { + if (!value) { + unreadSenderIds.value = {}; + hasUnread.value = false; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('chat_private_unread_senders'); + await prefs.setBool('chat_private_has_unread', false); + } catch (e) { + debugPrint('[ChatPrivateNotification] setUnread error: $e'); + } + } else { + hasUnread.value = true; + } + } + + /// Initialize the listener. Safe to call multiple times — will re-initialize. + static Future init({GlobalKey? key}) async { + if (key != null) navigatorKey = key; + + await dispose(); // bersihkan state sebelumnya + + try { + final prefs = await SharedPreferences.getInstance(); + final role = prefs.getString('user_role') ?? ''; + + if (role == 'ortu' || role == 'murid') { + await _initForMurid(prefs); + } else if (role == 'guru') { + await _initForGuru(); + } + + if (_currentUserId != null) { + final unreadList = prefs.getStringList('chat_private_unread_senders') ?? []; + unreadSenderIds.value = unreadList.toSet(); + hasUnread.value = unreadSenderIds.value.isNotEmpty || (prefs.getBool('chat_private_has_unread') ?? false); + + + _subscribeRealtime(); + debugPrint('[ChatPrivateNotification] initialized (role: $_currentUserRole, id: $_currentUserId, unread: ${unreadSenderIds.value})'); + } + // Admin tidak perlu notif chat private di sini + } catch (e) { + debugPrint('[ChatPrivateNotification] init error: $e'); + } + } + + // ─── Init untuk Ortu/Murid ──────────────────────────────────────────────── + + static Future _initForMurid(SharedPreferences prefs) async { + final nis = prefs.getString('murid_nis'); + final adminId = prefs.getString('user_id_admin'); + if (nis == null || adminId == null) return; + + final muridRes = await Supabase.instance.client + .from('murid') + .select('id_tabel') + .eq('user_id', adminId) + .eq('nis', nis) + .or('status_akun.is.null,status_akun.eq.true') + .maybeSingle(); + + if (muridRes == null) return; + + _currentUserId = muridRes['id_tabel'].toString(); + _currentUserRole = 'murid'; + } + + // ─── Init untuk Guru ────────────────────────────────────────────────────── + + static Future _initForGuru() async { + final guruInfo = await DbGuru().getLoggedInGuruInfo(); + _currentUserId = guruInfo['id_tabel']?.toString(); + _currentUserRole = 'guru'; + } + + // ─── Tangani Entrig event yang diteruskan dari fcm_notif.dart ────────────── + + // ─── Supabase Realtime ──────────────────────────────────────────────────── + + static void _subscribeRealtime() async { + final supabase = Supabase.instance.client; + final prefs = await SharedPreferences.getInstance(); + _lastSeenAt = DateTime.now().toUtc(); + + _streamSubscription = supabase + .from('chat_private') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: false) + .listen( + (rows) { + // Filter: only messages where recipient matches our id + final filtered = rows.where((row) { + final recipientId = _currentUserRole == 'guru' + ? row['penerima_guru']?.toString() + : row['penerima_murid']?.toString(); + return recipientId == _currentUserId; + }).toList(); + + // Filter: only new messages (after init / lastSeenAt) + final newMsgs = filtered.where((row) { + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + return t != null && t.isAfter(_lastSeenAt!); + }).toList(); + + for (final row in filtered) { + final senderId = row['pengirim_murid']?.toString() ?? row['pengirim_guru']?.toString() ?? row['pengirim_admin']?.toString(); + if (senderId != null && senderId != _currentUserId) { + final lastReadStr = prefs.getString('chat_private_last_read_$senderId'); + final lastRead = lastReadStr != null ? DateTime.parse(lastReadStr) : DateTime.fromMillisecondsSinceEpoch(0); + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + if (t != null && t.isAfter(lastRead) && !unreadSenderIds.value.contains(senderId)) { + setSenderUnread(senderId, true); + } + } + } + + for (final row in filtered) { + final senderId = row['pengirim_murid']?.toString() ?? row['pengirim_guru']?.toString() ?? row['pengirim_admin']?.toString(); + if (senderId != null && senderId != _currentUserId) { + final lastReadStr = prefs.getString('chat_private_last_read_$senderId'); + final lastRead = lastReadStr != null ? DateTime.parse(lastReadStr) : DateTime.fromMillisecondsSinceEpoch(0); + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + if (t != null && t.isAfter(lastRead) && !unreadSenderIds.value.contains(senderId)) { + setSenderUnread(senderId, true); + } + } + } + + if (newMsgs.isEmpty) return; + + // Update lastSeenAt + for (final row in newMsgs) { + final t = DateTime.tryParse(row['created_at'].toString()); + if (t != null && t.isAfter(_lastSeenAt!)) _lastSeenAt = t; + } + + debugPrint('[ChatPrivateNotification] stream → ${newMsgs.length} pesan baru'); + _handleRealtimeInsert(newMsgs.first); + }, + onError: (e) { + debugPrint('[ChatPrivateNotification] stream error: $e'); + }, + ); + } + + static void _handleRealtimeInsert(Map row) { + // Skip if sent by ourselves + final senderMurid = row['pengirim_murid']?.toString(); + final senderGuru = row['pengirim_guru']?.toString(); + final senderAdmin = row['pengirim_admin']?.toString(); + final senderId = senderMurid ?? senderGuru ?? senderAdmin; + + if (senderId != null && senderId == _currentUserId) { + return; + } + + if (senderId != null) { + setSenderUnread(senderId, true); + } + } + + // ─── Dispose ────────────────────────────────────────────────────────────── + + static Future dispose() async { + _streamSubscription?.cancel(); + _streamSubscription = null; + _lastSeenAt = null; + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/f_notif/fcm_notif.dart b/lib/dashboard/d_guru/function/f_pesan_g/f_notif/fcm_notif.dart new file mode 100644 index 0000000..705f19e --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/f_notif/fcm_notif.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class NotificationFCM { + // GlobalKey to perform navigation from anywhere in the app without BuildContext + static final GlobalKey navigatorKey = GlobalKey(); + + /// Initialize listeners for FCM notification click events and foreground messages + static Future init() async { + // 0. Ensure Firebase is initialized + try { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(); + debugPrint('[NotificationFCM] Firebase initialized'); + } else { + debugPrint('[NotificationFCM] Firebase default app already exists'); + } + } catch (e) { + debugPrint('[NotificationFCM] Error initializing Firebase: $e'); + } + + // 1. Request push notification permissions + try { + await FirebaseMessaging.instance.requestPermission( + alert: true, + announcement: false, + badge: true, + carPlay: false, + criticalAlert: false, + provisional: false, + sound: true, + ); + } catch (e) { + debugPrint('Error requesting notification permission: $e'); + } + + // 3. Listen for standard FCM Foreground Messages + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + _handleIncomingRemoteMessage(message); + }); + + // 4. Handle notification when the app is in the background and opened by tapping it + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { + _handleRemoteMessageOpened(message); + }); + + // 5. Handle notification when the app was terminated and opened by tapping it + try { + final RemoteMessage? initialMessage = await FirebaseMessaging.instance.getInitialMessage(); + if (initialMessage != null) { + _handleRemoteMessageOpened(initialMessage); + } + } catch (e) { + debugPrint('Error getting initial message: $e'); + } + } + + + + /// Check if the notification's nama_user matches the logged-in student's name + static Future _shouldShowNotification(Map? data) async { + if (data == null) return true; // Show by default if no data payload to filter + + final namaUser = _extractNamaUser(data); + if (namaUser == null || namaUser.isEmpty) { + return true; // Show by default if data doesn't contain user filter key + } + + final prefs = await SharedPreferences.getInstance(); + String? loggedInStudentName = prefs.getString('murid_nama'); + + // If cache not found, fetch it from Supabase and cache it + if (loggedInStudentName == null || loggedInStudentName.isEmpty) { + final nis = prefs.getString('murid_nis'); + final userId = prefs.getString('user_id_admin'); + if (nis != null && userId != null) { + try { + final response = await Supabase.instance.client + .from('murid') + .select('nama') + .eq('user_id', userId) + .eq('nis', nis) + .maybeSingle(); + + if (response != null) { + loggedInStudentName = response['nama']?.toString(); + if (loggedInStudentName != null) { + await prefs.setString('murid_nama', loggedInStudentName); + } + } + } catch (e) { + debugPrint('Error fetching student name for notification filter: $e'); + } + } + } + + if (loggedInStudentName != null && loggedInStudentName.isNotEmpty) { + final matches = loggedInStudentName.trim().toLowerCase() == namaUser.trim().toLowerCase(); + debugPrint('Notification filter - Logged in: "$loggedInStudentName", Incoming: "$namaUser". Match: $matches'); + return matches; + } + + return true; + } + + /// Extract nama_user field dynamically from map (handles nested objects from foreign keys) + static String? _extractNamaUser(Map data) { + if (data.containsKey('nama_user')) { + final val = data['nama_user']; + if (val is Map) { + return val['name']?.toString() ?? val['nama']?.toString(); + } + return val?.toString(); + } + + // Case insensitive fallback + for (var key in data.keys) { + if (key.toString().toLowerCase() == 'nama_user') { + final val = data[key]; + if (val is Map) { + return val['name']?.toString() ?? val['nama']?.toString(); + } + return val?.toString(); + } + } + return null; + } + + /// Handle incoming standard FCM foreground messages + static Future _handleIncomingRemoteMessage(RemoteMessage message) async { + final shouldShow = await _shouldShowNotification(message.data); + if (!shouldShow) return; + + final title = message.notification?.title ?? message.data['title']?.toString() ?? 'Absensi Digital'; + final body = message.notification?.body ?? message.data['body']?.toString() ?? 'Melakukan Check-in'; + + debugPrint('[NotificationFCM] Incoming foreground FCM message: Title: $title, Body: $body'); + } + + /// Handle when standard FCM notification is clicked + static void _handleRemoteMessageOpened(RemoteMessage message) { + // No-op in Guru application + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/f_notif/grup_kelas_n.dart b/lib/dashboard/d_guru/function/f_pesan_g/f_notif/grup_kelas_n.dart new file mode 100644 index 0000000..018c036 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/f_notif/grup_kelas_n.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_guru/database/db_grup_kelas.dart'; + + +/// Notification handler for Grup Kelas chat. +/// Must call [GrupKelasNotification.init()] once after login. +/// Call [GrupKelasNotification.dispose()] on logout. +class GrupKelasNotification { + // GlobalKey provided by the root MaterialApp (shared with NotificationFCM) + static GlobalKey? navigatorKey; + + + + static String? _currentUserId; + static String? _currentUserRole; + static String? _currentClassId; + + // Supabase stream subscription + static StreamSubscription>>? _streamSubscription; + static DateTime? _lastSeenAt; + + static final ValueNotifier hasUnread = ValueNotifier(false); + + static Future setUnread(bool value) async { + hasUnread.value = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('grup_kelas_has_unread', value); + if (!value) { + await prefs.setString('grup_kelas_last_read', DateTime.now().toUtc().toIso8601String()); + _lastSeenAt = DateTime.now().toUtc(); + } + if (!value) { + await prefs.setString('grup_kelas_last_read', DateTime.now().toUtc().toIso8601String()); + _lastSeenAt = DateTime.now().toUtc(); + } + } catch (e) { + debugPrint('[GrupKelasNotification] setUnread error: $e'); + } + } + + /// Initialize the listener. Safe to call multiple times — will re-initialize. + static Future init({GlobalKey? key}) async { + if (key != null) navigatorKey = key; + + await dispose(); // bersihkan state sebelumnya + + try { + final info = await DbGrupKelas.getCurrentUserRoleAndId(); + if (info == null) return; + + _currentUserId = info['id']; + _currentUserRole = info['role']; + _currentClassId = info['id_class']; + + if (_currentClassId == null || _currentClassId!.isEmpty) return; + + final prefs = await SharedPreferences.getInstance(); + hasUnread.value = prefs.getBool('grup_kelas_has_unread') ?? false; + + + + _subscribeRealtime(); + + debugPrint('[GrupKelasNotification] initialized (role: $_currentUserRole, class: $_currentClassId)'); + } catch (e) { + debugPrint('[GrupKelasNotification] init error: $e'); + } + } + + // ─── Supabase Realtime ──────────────────────────────────────────────────── + + static void _subscribeRealtime() { + final supabase = Supabase.instance.client; + _lastSeenAt = DateTime.now().toUtc(); + + _streamSubscription = supabase + .from('grup_kelas') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: false) + .listen( + (rows) { + // Filter: only messages for the same class_id + final filtered = _currentClassId != null + ? rows.where((r) => r['id_class']?.toString() == _currentClassId).toList() + : rows; + + // Filter: only new messages (after init / lastSeenAt) + final newMsgs = filtered.where((row) { + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + return t != null && t.isAfter(_lastSeenAt!.subtract(const Duration(minutes: 5))); + }).toList(); + + if (newMsgs.isEmpty) return; + + // Update lastSeenAt + for (final row in newMsgs) { + final t = DateTime.tryParse(row['created_at'].toString()); + if (t != null && t.isAfter(_lastSeenAt!)) _lastSeenAt = t; + } + + debugPrint('[GrupKelasNotification] stream → ${newMsgs.length} pesan baru'); + _handleRealtimeInsert(newMsgs.first); + }, + onError: (e) { + debugPrint('[GrupKelasNotification] stream error: $e'); + }, + ); + } + + static void _handleRealtimeInsert(Map row) { + // Skip if sent by ourselves + final pengirimMurid = row['pengirim_murid']?.toString(); + final pengirimGuru = row['pengirim_guru']?.toString(); + final pengirimAdmin = row['pengirim_admin']?.toString(); + final senderId = pengirimMurid ?? pengirimGuru ?? pengirimAdmin; + + if (senderId != null && senderId == _currentUserId) { + return; + } + + final text = row['text']?.toString() ?? ''; + debugPrint('[GrupKelasNotification] Realtime → pesan baru: "$text"'); + + setUnread(true); + } + + static Future dispose() async { + _streamSubscription?.cancel(); + _streamSubscription = null; + _lastSeenAt = null; + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/f_notif/grup_sekolah_n.dart b/lib/dashboard/d_guru/function/f_pesan_g/f_notif/grup_sekolah_n.dart new file mode 100644 index 0000000..1e1a155 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/f_notif/grup_sekolah_n.dart @@ -0,0 +1,159 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_guru/database/db_grup_kelas.dart'; + + + +/// Notification handler for Grup Sekolah chat. +/// +/// Menggunakan dua mekanisme: +/// 1. **Supabase Realtime** — foreground notification utama (tidak bergantung FCM/Entrig). +/// Langsung listen INSERT pada tabel `grup_sekolah` dan tampilkan overlay. +/// 2. **Entrig callback** — fallback dari [NotificationFCM] jika event datang via FCM. +/// +/// Must call [GrupSekolahNotification.init()] once after login. +/// Call [GrupSekolahNotification.dispose()] on logout. +class GrupSekolahNotification { + // GlobalKey provided by the root MaterialApp (shared with NotificationFCM) + static GlobalKey? navigatorKey; + + + + static String? _currentUserId; + static String? _currentUserRole; + static String? _currentAdminId; // untuk filter per-sekolah (user_id di grup_sekolah) + + // Supabase stream subscription (lebih reliable dari onPostgresChanges) + static StreamSubscription>>? _streamSubscription; + static DateTime? _lastSeenAt; // untuk filter pesan baru saja + + static final ValueNotifier hasUnread = ValueNotifier(false); + + static Future setUnread(bool value) async { + hasUnread.value = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('grup_sekolah_has_unread', value); + if (!value) { + await prefs.setString('grup_sekolah_last_read', DateTime.now().toUtc().toIso8601String()); + _lastSeenAt = DateTime.now().toUtc(); + } + if (!value) { + await prefs.setString('grup_sekolah_last_read', DateTime.now().toUtc().toIso8601String()); + _lastSeenAt = DateTime.now().toUtc(); + } + } catch (e) { + debugPrint('[GrupSekolahNotification] setUnread error: $e'); + } + } + + /// Initialize listener. Safe to call multiple times — will re-initialize. + static Future init({GlobalKey? key}) async { + if (key != null) navigatorKey = key; + + dispose(); // bersihkan state sebelumnya + + try { + final info = await DbGrupKelas.getCurrentUserRoleAndId(); + if (info == null) return; + + _currentUserId = info['id']; + _currentUserRole = info['role']; + + // Ambil admin_id dari SharedPreferences untuk filter per-sekolah + final prefs = await SharedPreferences.getInstance(); + _currentAdminId = prefs.getString('user_id_admin'); + + // Load status unread + hasUnread.value = prefs.getBool('grup_sekolah_has_unread') ?? false; + + + + // 2. Supabase Realtime (mekanisme utama foreground) + _subscribeRealtime(); + + debugPrint( + '[GrupSekolahNotification] initialized ' + '(role: $_currentUserRole, adminId: $_currentAdminId)', + ); + } catch (e) { + debugPrint('[GrupSekolahNotification] init error: $e'); + } + } + + // ─── Supabase Realtime ──────────────────────────────────────────────────── + + static void _subscribeRealtime() async { + final supabase = Supabase.instance.client; + final prefs = await SharedPreferences.getInstance(); + final lastReadStr = prefs.getString('grup_sekolah_last_read'); + _lastSeenAt = lastReadStr != null ? DateTime.parse(lastReadStr) : DateTime.now().toUtc(); + + _streamSubscription = supabase + .from('grup_sekolah') + .stream(primaryKey: ['id_tabel']) + .order('created_at', ascending: false) + .listen( + (rows) { + // Filter: hanya pesan dari sekolah yang sama (client-side) + final filtered = _currentAdminId != null + ? rows.where((r) => r['user_id']?.toString() == _currentAdminId).toList() + : rows; + + // Filter: hanya pesan baru (setelah waktu init / lastSeenAt) + final newMsgs = filtered.where((row) { + final t = DateTime.tryParse(row['created_at']?.toString() ?? ''); + return t != null && t.isAfter(_lastSeenAt!.subtract(const Duration(minutes: 5))); + }).toList(); + + if (newMsgs.isEmpty) return; + + // Update lastSeenAt ke pesan paling baru + for (final row in newMsgs) { + final t = DateTime.tryParse(row['created_at'].toString()); + if (t != null && t.isAfter(_lastSeenAt!)) _lastSeenAt = t; + } + + debugPrint('[GrupSekolahNotification] stream → ${newMsgs.length} pesan baru'); + // Tampilkan notifikasi untuk pesan pertama (terbaru) + _handleRealtimeInsert(newMsgs.first); + }, + onError: (e) { + debugPrint('[GrupSekolahNotification] stream error: $e'); + }, + ); + + debugPrint('[GrupSekolahNotification] stream subscribed (lastSeenAt: $_lastSeenAt)'); + } + + /// Tangani INSERT baru dari Supabase Realtime. + static void _handleRealtimeInsert(Map row) { + // Skip jika pesan dari user ini sendiri + final pengirimMurid = row['pengirim_murid']?.toString(); + final pengirimGuru = row['pengirim_guru']?.toString(); + final pengirimAdmin = row['pengirim_admin']?.toString(); + final senderId = pengirimMurid ?? pengirimGuru ?? pengirimAdmin; + + if (senderId != null && senderId == _currentUserId) { + debugPrint('[GrupSekolahNotification] Skip own message'); + return; + } + + final text = row['text']?.toString() ?? ''; + debugPrint('[GrupSekolahNotification] Realtime → pesan baru: "$text"'); + + setUnread(true); + } + + // ─── Lifecycle ──────────────────────────────────────────────────────────── + + /// Bersihkan listener. + static void dispose() { + // Cancel stream subscription + _streamSubscription?.cancel(); + _streamSubscription = null; + _lastSeenAt = null; + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/grup_kelas_g.dart b/lib/dashboard/d_guru/function/f_pesan_g/grup_kelas_g.dart new file mode 100644 index 0000000..5dba8c0 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/grup_kelas_g.dart @@ -0,0 +1,918 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:apk/dashboard/d_guru/database/db_grup_kelas.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/anggota_g/member_kelas_g.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:apk/dashboard/d_guru/database/storage/image_doc.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/doc_image.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/pengumuman.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/grup_kelas_n.dart'; + +class GrupKelasPageG extends StatefulWidget { + const GrupKelasPageG({super.key}); + + @override + State createState() => _GrupKelasPageGState(); +} + +class _GrupKelasPageGState extends State { + final TextEditingController _messageController = TextEditingController(); + + bool _isLoading = true; + String? _errorMessage; + + String? _currentUserId; + String? _currentUserRole; // 'admin', 'guru', or 'murid' + String? _currentClassId; + + Stream>>? _chatStream; + Map _userNames = {}; // mapping ID ke Nama + + @override + void initState() { + super.initState(); + _initChat(); + GrupKelasNotification.setUnread(false); + } + + Future _initChat() async { + try { + // Fetch mapping nama semua user + _userNames = await DbGrupKelas.fetchAllNames(); + + // Fetch current user info + final currentUserInfo = await DbGrupKelas.getCurrentUserRoleAndId(); + + if (currentUserInfo != null) { + _currentUserId = currentUserInfo['id']; + _currentUserRole = currentUserInfo['role']; + _currentClassId = currentUserInfo['id_class']; + + if (_currentUserRole == 'admin') { + _errorMessage = 'Admin tidak tergabung secara default dalam satu grup kelas spesifik.'; + } else if (_currentClassId == null || _currentClassId!.isEmpty) { + _errorMessage = 'Anda belum terdaftar di kelas manapun.'; + } else { + _chatStream = DbGrupKelas().getGrupKelasStream(_currentClassId!); + } + } else { + _errorMessage = 'Gagal memuat profil. Pastikan Anda telah login dengan benar.'; + } + } catch (e) { + _errorMessage = 'Error: $e'; + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + await DbGrupKelas().sendMessage( + text: 'IMAGE_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + idClass: _currentClassId!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + await DbGrupKelas().sendMessage( + text: 'DOC_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + idClass: _currentClassId!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _currentUserId == null || _currentUserRole == null || _currentClassId == null) return; + + _messageController.clear(); + + try { + await DbGrupKelas().sendMessage( + text: text, + senderId: _currentUserId!, + role: _currentUserRole!, + idClass: _currentClassId!, + ); + + // Reload stream dari database setelah mengirim pesan + if (mounted) { + setState(() { + _isLoading = true; + }); + await _initChat(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + String _getSenderId(Map msg) { + if (msg['pengirim_admin'] != null) return msg['pengirim_admin'].toString(); + if (msg['pengirim_guru'] != null) return msg['pengirim_guru'].toString(); + if (msg['pengirim_murid'] != null) return msg['pengirim_murid'].toString(); + return ''; + } + + Widget _buildIzinCard(String text, String senderName, String time, bool isMe) { + final lines = text.split('\n'); + String nama = ''; + String tanggal = ''; + String keterangan = ''; + List lampiranUrls = []; + String idIzin = ''; + String jenis = ''; + + for (var rawLine in lines) { + final line = rawLine.trim(); + if (line.startsWith('Nama: ')) nama = line.substring(6).trim(); + else if (line.startsWith('Tanggal: ')) tanggal = line.substring(9).trim(); + else if (line.startsWith('Keterangan: ')) keterangan = line.substring(12).trim(); + else if (line.startsWith('Lampiran: ')) { + final urlsString = line.substring(10).trim(); + if (urlsString.isNotEmpty) { + lampiranUrls = urlsString.split(RegExp(r'\s+')).where((s) => s.startsWith('http')).toList(); + } + } + else if (line.startsWith('ID_IZIN: ')) idIzin = line.substring(8).trim(); + else if (line.startsWith('Jenis: ')) jenis = line.substring(7).trim(); + else if (keterangan.isNotEmpty && lampiranUrls.isEmpty && !line.startsWith('📋') && !line.startsWith('ID_IZIN:') && !line.startsWith('Jenis:')) { + keterangan += '\n$line'; + } + } + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.85, + ), + child: Card( + color: Colors.amber[50], + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + jenis.toLowerCase() == 'sakit' ? Icons.sick : Icons.assignment_ind, + color: jenis.toLowerCase() == 'sakit' ? Colors.red : Colors.amber, + size: 28, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + jenis.isNotEmpty ? 'Pengajuan $jenis' : 'Pengajuan Izin', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ], + ), + const Divider(), + Row( + children: [ + const Icon(Icons.person, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + nama, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black87), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.date_range, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + tanggal, + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ], + ), + if (keterangan.isNotEmpty) ...[ + const SizedBox(height: 8), + const Text('Keterangan:', style: TextStyle(fontSize: 12, color: Colors.grey)), + Text(keterangan, style: const TextStyle(fontSize: 14)), + ], + if (lampiranUrls.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: lampiranUrls.map((urlStr) { + final uri = Uri.tryParse(urlStr); + String extension = 'file'; + String cleanName = 'Lampiran'; + if (uri != null && uri.pathSegments.isNotEmpty) { + final fullName = Uri.decodeComponent(uri.pathSegments.last); + cleanName = fullName.replaceFirst(RegExp(r'^\d{13}_(?:camera_)?'), ''); + final extIdx = fullName.lastIndexOf('.'); + if (extIdx != -1 && extIdx < fullName.length - 1) { + extension = fullName.substring(extIdx + 1).toLowerCase(); + } + } + final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); + final extLabel = extension.length > 4 + ? extension.substring(0, 4).toUpperCase() + : extension.toUpperCase(); + + if (isImage) { + return GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute( + builder: (_) => DocImagePreviewPage(imageUrl: urlStr), + )); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + urlStr, + width: 110, + height: 110, + fit: BoxFit.cover, + loadingBuilder: (ctx, child, progress) => + progress == null ? child : const SizedBox(width: 110, height: 110, child: Center(child: CircularProgressIndicator(strokeWidth: 2))), + errorBuilder: (ctx, _, __) => const SizedBox(width: 110, height: 110, child: Icon(Icons.broken_image)), + ), + ), + ); + } else { + return GestureDetector( + onTap: () async { + if (uri != null) { + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tidak dapat membuka lampiran')), + ); + } + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 220), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.85), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.black12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extLabel, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cleanName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + '$extLabel • Dokumen', + style: const TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ), + ), + ], + ), + ), + ); + } + }).toList(), + ), + ], + if (idIzin.isNotEmpty) ...[ + const Divider(), + IzinStatusWidget(idIzin: idIzin, isTeacher: true), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.bottomRight, + child: Text( + time, + style: const TextStyle(fontSize: 10, color: Colors.black54), + ), + ), + ], + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Grup Kelas'), + backgroundColor: const Color(0xFF2563EB), + iconTheme: const IconThemeData(color: Colors.white), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + actions: [ + IconButton( + icon: const Icon(Icons.people), + tooltip: 'Anggota Kelas', + onPressed: () { + if (_currentClassId != null && _currentClassId!.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MemberKelasPageG(classId: _currentClassId!), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Anda belum tergabung di kelas mana pun')), + ); + } + }, + ) + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center(child: Padding(padding: const EdgeInsets.all(16), child: Text(_errorMessage!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.red)))) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center(child: Text('Belum ada pesan di kelas ini')); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + final senderId = _getSenderId(msg); + final isMe = senderId == _currentUserId; + final senderName = isMe ? 'Anda' : (_userNames[senderId] ?? 'Tidak diketahui'); + + final textMsg = msg['text'] ?? ''; + if (textMsg.startsWith("📋 *Pengajuan Izin*")) { + return _buildIzinCard(textMsg, senderName, _formatTime(msg['created_at']), isMe); + } + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (!isMe) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + senderName, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF2563EB), + ), + ), + ), + if (msg['text']?.toString().startsWith('PENGUMUMAN_ID:') == true) + PengumumanCard(pengumumanId: msg['text'].toString().split(':')[1]) + else if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -160), + onSelected: (value) { + if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } else if (value == 'announcement') { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PengumumanPage(classId: _currentClassId), + ), + ); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + if (_currentUserRole == 'admin' || _currentUserRole == 'guru') + const PopupMenuItem( + value: 'announcement', + child: Row( + children: [ + Icon(Icons.campaign, color: Colors.orange), + SizedBox(width: 8), + Text('Pengumuman'), + ], + ), + ), + ], + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan kelas...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class IzinStatusWidget extends StatefulWidget { + final String idIzin; + final bool isTeacher; + const IzinStatusWidget({super.key, required this.idIzin, required this.isTeacher}); + + @override + State createState() => _IzinStatusWidgetState(); +} + +class _IzinStatusWidgetState extends State { + bool? _verifStatus; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchStatus(); + } + + Future _fetchStatus() async { + if (widget.idIzin.isEmpty) { + setState(() => _isLoading = false); + return; + } + try { + final res = await Supabase.instance.client + .from('leave_request') + .select('verif') + .eq('id_tabel', widget.idIzin) + .maybeSingle(); + if (res != null && mounted) { + setState(() { + _verifStatus = res['verif']; + _isLoading = false; + }); + } else { + setState(() => _isLoading = false); + } + } catch (e) { + setState(() => _isLoading = false); + } + } + + Future _updateStatus(bool status) async { + setState(() => _isLoading = true); + try { + await Supabase.instance.client + .from('leave_request') + .update({'verif': status}) + .eq('id_tabel', widget.idIzin); + + if (mounted) { + setState(() { + _verifStatus = status; + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(status ? 'Pengajuan izin disetujui!' : 'Pengajuan izin ditolak!'), + backgroundColor: status ? Colors.green : Colors.red, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal memperbarui status: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + if (_verifStatus == true) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: Color(0xFF059669), size: 16), + SizedBox(width: 6), + Text( + 'Disetujui', + style: TextStyle(color: Color(0xFF059669), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } + + if (_verifStatus == false) { + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cancel, color: Color(0xFFDC2626), size: 16), + SizedBox(width: 6), + Text( + 'Ditolak', + style: TextStyle(color: Color(0xFFDC2626), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } + + // Status: Pending (null) + if (widget.isTeacher) { + return Container( + margin: const EdgeInsets.only(top: 12), + child: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _updateStatus(true), + icon: const Icon(Icons.check, size: 16, color: Colors.white), + label: const Text('Setujui', style: TextStyle(color: Colors.white, fontFamily: 'Inter', fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF059669), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: () => _updateStatus(false), + icon: const Icon(Icons.close, size: 16, color: Color(0xFFDC2626)), + label: const Text('Tolak', style: TextStyle(color: Color(0xFFDC2626), fontFamily: 'Inter', fontWeight: FontWeight.bold)), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFDC2626)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ), + ); + } + + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.hourglass_empty, color: Color(0xFFD97706), size: 16), + SizedBox(width: 6), + Text( + 'Menunggu Persetujuan', + style: TextStyle(color: Color(0xFFD97706), fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'Inter'), + ), + ], + ), + ); + } +} + diff --git a/lib/dashboard/d_guru/function/f_pesan_g/grup_sekolah_g.dart b/lib/dashboard/d_guru/function/f_pesan_g/grup_sekolah_g.dart new file mode 100644 index 0000000..e19eda2 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/grup_sekolah_g.dart @@ -0,0 +1,479 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:apk/dashboard/d_guru/database/db_grup_sekolah.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/anggota_g/member_sekolah_g.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/pengumuman.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/d_guru/database/storage/image_doc.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/doc_image.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/f_notif/grup_sekolah_n.dart'; + +class GrupSekolahG extends StatefulWidget { + const GrupSekolahG({super.key}); + + @override + State createState() => _GrupSekolahGState(); +} + +class _GrupSekolahGState extends State { + final TextEditingController _messageController = TextEditingController(); + + bool _isLoading = true; + String? _errorMessage; + + String? _currentUserId; + String? _currentUserRole; // 'admin', 'guru', or 'murid' + + Stream>>? _chatStream; + Map _userNames = {}; // mapping ID ke Nama + + @override + void initState() { + super.initState(); + _initChat(); + GrupSekolahNotification.setUnread(false); + } + + Future _initChat() async { + try { + // Fetch mapping nama semua user + _userNames = await DbGrupSekolah.fetchAllNames(); + + // Fetch current user info + final currentUserInfo = await DbGrupSekolah.getCurrentUserRoleAndId(); + + if (currentUserInfo != null) { + _currentUserId = currentUserInfo['id']; + _currentUserRole = currentUserInfo['role']; + _chatStream = DbGrupSekolah().getGrupSekolahStream(); + } else { + _errorMessage = 'Gagal memuat profil. Pastikan Anda telah login dengan benar.'; + } + } catch (e) { + _errorMessage = 'Error: $e'; + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _pickAndSendImage() async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + final file = File(pickedFile.path); + final name = pickedFile.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFileObject(file, name); + if (url != null) { + await DbGrupSekolah().sendMessage( + text: 'IMAGE_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah gambar'))); + } + } + } + + Future _pickAndSendDoc() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'zip', 'rar'], + withData: true, + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final name = result.files.single.name; + setState(() => _isLoading = true); + final url = await ImageDocStorage.uploadFile(bytes, name); + if (url != null) { + await DbGrupSekolah().sendMessage( + text: 'DOC_URL:$url', + senderId: _currentUserId!, + role: _currentUserRole!, + ); + await _initChat(); + } else { + setState(() => _isLoading = false); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gagal unggah dokumen'))); + } + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _currentUserId == null || _currentUserRole == null) return; + + _messageController.clear(); + + try { + await DbGrupSekolah().sendMessage( + text: text, + senderId: _currentUserId!, + role: _currentUserRole!, + ); + + // Reload stream dari database setelah mengirim pesan + if (mounted) { + setState(() { + _isLoading = true; + }); + await _initChat(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + String _formatTime(String isoString) { + try { + final date = DateTime.parse(isoString).toLocal(); + return DateFormat('HH:mm').format(date); + } catch (_) { + return ''; + } + } + + String _getSenderId(Map msg) { + if (msg['pengirim_admin'] != null) return msg['pengirim_admin'].toString(); + if (msg['pengirim_guru'] != null) return msg['pengirim_guru'].toString(); + if (msg['pengirim_murid'] != null) return msg['pengirim_murid'].toString(); + return ''; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Grup Sekolah'), + backgroundColor: const Color(0xFF2563EB), + iconTheme: const IconThemeData(color: Colors.white), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + actions: [ + IconButton( + icon: const Icon(Icons.people), + tooltip: 'Anggota Grup', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MemberSekolah()), + ); + }, + ) + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center(child: Padding(padding: const EdgeInsets.all(16), child: Text(_errorMessage!, style: const TextStyle(color: Colors.red)))) + : Column( + children: [ + Expanded( + child: _chatStream == null + ? const Center(child: Text('Gagal memuat ruang obrolan')) + : StreamBuilder>>( + stream: _chatStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final messages = snapshot.data ?? []; + + if (messages.isEmpty) { + return const Center(child: Text('Belum ada pesan. Mulai obrolan!')); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[messages.length - 1 - index]; + bool isMe = false; + if (_currentUserRole == 'admin') { + isMe = msg['pengirim_admin']?.toString() == _currentUserId; + } else if (_currentUserRole == 'guru') { + isMe = msg['pengirim_guru']?.toString() == _currentUserId; + } else if (_currentUserRole == 'murid') { + isMe = msg['pengirim_murid']?.toString() == _currentUserId; + } + final senderId = _getSenderId(msg); + final senderName = _userNames[senderId] ?? 'Pengguna'; + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (!isMe) + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 2), + child: Text( + senderName, + style: const TextStyle( + fontSize: 12, + color: Colors.black54, + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF2563EB) : Colors.grey[200], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(0), + bottomRight: isMe ? const Radius.circular(0) : const Radius.circular(16), + ), + ), + child: Column( + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (msg['text']?.toString().startsWith('PENGUMUMAN_ID:') == true) + PengumumanCard(pengumumanId: msg['text'].toString().split(':')[1]) + else if (msg['text']?.toString().startsWith('IMAGE_URL:') == true) + GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: msg['text'].toString().replaceFirst('IMAGE_URL:', '')))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + msg['text'].toString().replaceFirst('IMAGE_URL:', ''), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ) + else if (msg['text']?.toString().startsWith('DOC_URL:') == true) + Builder( + builder: (context) { + final url = msg['text'].toString().replaceFirst('DOC_URL:', ''); + final uri = Uri.tryParse(url); + String fileName = 'Dokumen'; + String extension = 'FILE'; + + if (uri != null && uri.pathSegments.isNotEmpty) { + fileName = Uri.decodeComponent(uri.pathSegments.last); + final regex = RegExp(r'^\d{13}_'); + fileName = fileName.replaceFirst(regex, ''); + final extIndex = fileName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fileName.length - 1) { + extension = fileName.substring(extIndex + 1).toUpperCase(); + } + } + + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(url); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka dokumen'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isMe ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: isMe ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4) : extension, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: isMe ? Colors.white : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '$extension • Dokumen', + style: TextStyle( + fontSize: 12, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ) + else + Text( + msg['text'] ?? '', + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(msg['created_at']), + style: TextStyle( + fontSize: 10, + color: isMe ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + ), + child: Row( + children: [ + if (_currentUserRole == 'admin') + IconButton( + icon: const Icon(Icons.campaign, color: Colors.orange), + tooltip: 'Buat Pengumuman', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const PengumumanPage()), + ); + }, + ), + PopupMenuButton( + icon: const Icon(Icons.attach_file, color: Colors.orange), + tooltip: 'Lampiran', + offset: const Offset(0, -120), + onSelected: (value) { + if (value == 'image') { + _pickAndSendImage(); + } else if (value == 'document') { + _pickAndSendDoc(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'image', + child: Row( + children: [ + Icon(Icons.image, color: Colors.blue), + SizedBox(width: 8), + Text('Gambar'), + ], + ), + ), + const PopupMenuItem( + value: 'document', + child: Row( + children: [ + Icon(Icons.insert_drive_file, color: Colors.red), + SizedBox(width: 8), + Text('Dokumen'), + ], + ), + ), + ], + ), + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Tulis pesan...', + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF2563EB), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/pengumuman.dart b/lib/dashboard/d_guru/function/f_pesan_g/pengumuman.dart new file mode 100644 index 0000000..67098ae --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/pengumuman.dart @@ -0,0 +1,569 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:apk/dashboard/d_guru/database/db_pengumuman.dart'; +import 'package:apk/dashboard/d_guru/database/storage/doc_pengumuman.dart'; +import 'package:apk/dashboard/d_guru/database/db_grup_sekolah.dart'; +import 'package:apk/dashboard/d_guru/database/db_grup_kelas.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/doc_image.dart'; + +class PengumumanPage extends StatefulWidget { + final String? classId; + const PengumumanPage({super.key, this.classId}); + + @override + State createState() => _PengumumanPageState(); +} + +class _PengumumanPageState extends State { + final _titleController = TextEditingController(); + final _keteranganController = TextEditingController(); + DateTime? _tanggalMulai; + DateTime? _tanggalSelesai; + List _selectedFiles = []; + bool _isLoading = false; + + Future _pickFile() async { + FilePickerResult? result = await FilePicker.pickFiles( + type: FileType.any, + allowMultiple: true, + withData: true, + ); + + if (result != null) { + setState(() { + for (var file in result.files) { + if (file.bytes != null) { + _selectedFiles.add(file); + } + } + }); + } + } + + Future _selectDate(BuildContext context, bool isStart) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + if (picked != null) { + setState(() { + if (isStart) { + _tanggalMulai = picked; + } else { + _tanggalSelesai = picked; + } + }); + } + } + + Future _submit() async { + if (_titleController.text.isEmpty || _tanggalMulai == null || _tanggalSelesai == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Harap lengkapi Title, Tanggal Mulai, dan Tanggal Selesai')), + ); + return; + } + + setState(() => _isLoading = true); + + try { + // 1. Upload File if any + List uploadedUrls = []; + for (var file in _selectedFiles) { + if (file.bytes != null && file.extension != null) { + await Future.delayed(const Duration(milliseconds: 10)); // Ensure unique timestamp + final url = await DocPengumuman.uploadDoc(file.bytes!, _titleController.text, file.extension!); + if (url != null) uploadedUrls.add(url); + } + } + + // 2. Determine Guru ID if sender is guru + final roleInfo = await DbGrupSekolah.getCurrentUserRoleAndId(); + String? idGuru; + if (roleInfo != null && roleInfo['role'] == 'guru') { + idGuru = roleInfo['id']; + } + + // 3. Insert Pengumuman + String keterangan = _keteranganController.text; + if (uploadedUrls.isNotEmpty) { + keterangan += '\n\nLampiran: ${uploadedUrls.join(' ')}'; + } + + final pengumuman = await DbPengumuman.insertPengumuman( + title: _titleController.text, + tanggalMulai: DateFormat('yyyy-MM-dd').format(_tanggalMulai!), + tanggalSelesai: DateFormat('yyyy-MM-dd').format(_tanggalSelesai!), + keterangan: keterangan, + idGuru: idGuru, + ); + + final pengumumanId = pengumuman['id_tabel']; + + // 4. Send to Grup Sekolah or Grup Kelas + if (roleInfo != null) { + if (widget.classId != null && widget.classId!.isNotEmpty) { + await DbGrupKelas().sendMessage( + text: 'PENGUMUMAN_ID:$pengumumanId', + senderId: roleInfo['id']!, + role: roleInfo['role']!, + idClass: widget.classId!, + ); + } else { + await DbGrupSekolah().sendMessage( + text: 'PENGUMUMAN_ID:$pengumumanId', + senderId: roleInfo['id']!, + role: roleInfo['role']!, + ); + } + } + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.classId != null && widget.classId!.isNotEmpty + ? 'Pengumuman berhasil dikirim ke Grup Kelas' + : 'Pengumuman berhasil dikirim ke Grup Sekolah'), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + automaticallyImplyLeading: true, + leading: IconButton( + icon: Image.asset('lib/assets/icons/Button.png', width: 30, height: 30), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: -5, + title: const Text("Buat Pengumuman"), + titleTextStyle: const TextStyle( + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + backgroundColor: Colors.white, + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Title + const Text("Title", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + TextField( + controller: _titleController, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: "Judul Pengumuman (Title)", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + + // Dates + Row( + children: [ + Expanded(child: _buildDateCard("Tanggal Mulai", _tanggalMulai, true)), + const SizedBox(width: 12), + Expanded(child: _buildDateCard("Tanggal Selesai", _tanggalSelesai, false)), + ], + ), + const SizedBox(height: 16), + + // Keterangan + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Keterangan", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 16)), + Row( + children: const [ + Icon(Icons.format_size, size: 16, color: Colors.grey), + SizedBox(width: 4), + Icon(Icons.edit, size: 16, color: Colors.grey), + ], + ) + ], + ), + const SizedBox(height: 8), + TextField( + controller: _keteranganController, + maxLines: 5, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: "Tuliskan isi pengumuman secara detail di sini...", + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + + // Upload + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: _pickFile, + icon: const Icon(Icons.upload_file, color: Colors.purple), + label: const Text('Upload Doc/Image', style: TextStyle(fontFamily: 'Inter', color: Colors.purple)), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + side: const BorderSide(color: Colors.purple, width: 1), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + ), + ), + ), + if (_selectedFiles.isNotEmpty) ...[ + const SizedBox(height: 16), + const Text("Uploaded Files : ", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedFiles.map((file) { + final ext = file.extension?.toLowerCase() ?? ''; + final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isImage ? Icons.image : Icons.picture_as_pdf, + color: isImage ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + file.name, + style: const TextStyle(fontFamily: 'Inter', fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () { + setState(() { + _selectedFiles.remove(file); + }); + }, + child: const Icon(Icons.close, size: 14, color: Colors.grey), + ), + ], + ), + ); + }).toList(), + ), + ], + const SizedBox(height: 32), + + // Submit Button + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1E3A8A), // Dark blue + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Tulis Pengumuman', style: TextStyle(fontFamily: 'Inter', fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ), + ], + ), + ), + ); + } + + Widget _buildDateCard(String title, DateTime? date, bool isStart) { + return GestureDetector( + onTap: () => _selectDate(context, isStart), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ] + ), + child: Row( + children: [ + const Icon(Icons.calendar_today, color: Color(0xFF2563EB)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.bold, fontSize: 12)), + const SizedBox(height: 4), + Text( + date == null ? '-' : DateFormat('dd MMM yyyy').format(date), + style: const TextStyle(fontFamily: 'Inter', fontSize: 12, color: Colors.black87), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// Widget untuk menampilkan Pengumuman di dalam obrolan Grup Sekolah +class PengumumanCard extends StatelessWidget { + final String pengumumanId; + const PengumumanCard({super.key, required this.pengumumanId}); + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: DbPengumuman.getPengumumanById(pengumumanId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + height: 50, + width: 50, + child: Center(child: CircularProgressIndicator()), + ), + ), + ); + } + if (!snapshot.hasData || snapshot.data == null) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('Pengumuman tidak ditemukan'), + ), + ); + } + + final data = snapshot.data!; + + // Memisahkan keterangan dan URL lampiran jika ada + String textKeterangan = data['keterangan'] ?? ''; + List lampiranUrls = []; + + if (textKeterangan.contains('Lampiran: ')) { + final parts = textKeterangan.split('Lampiran: '); + textKeterangan = parts[0].trim(); + final urlsString = parts.length > 1 ? parts[1].trim() : ''; + lampiranUrls = urlsString.split(RegExp(r'\s+')).where((s) => s.startsWith('http')).toList(); + } + + return Card( + color: Colors.amber[50], + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.campaign, color: Colors.amber, size: 28), + const SizedBox(width: 8), + Expanded( + child: Text( + data['title'] ?? 'Pengumuman', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ], + ), + const Divider(), + Row( + children: [ + const Icon(Icons.date_range, size: 16, color: Colors.grey), + const SizedBox(width: 4), + Text( + '${data['tanggal_mulai']} s/d ${data['tanggal_selesai']}', + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ], + ), + if (textKeterangan.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(textKeterangan, style: const TextStyle(fontSize: 14)), + ], + if (lampiranUrls.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: lampiranUrls.map((lampiranUrl) { + return Builder( + builder: (context) { + final uri = Uri.tryParse(lampiranUrl); + String extension = 'FILE'; + String fileName = 'Lampiran'; + if (uri != null && uri.pathSegments.isNotEmpty) { + final fullName = Uri.decodeComponent(uri.pathSegments.last); + fileName = fullName.replaceFirst(RegExp(r'^\d{13}_'), ''); + final extIndex = fullName.lastIndexOf('.'); + if (extIndex != -1 && extIndex < fullName.length - 1) { + extension = fullName.substring(extIndex + 1).toLowerCase(); + } + } + bool isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(extension); + + if (isImage) { + return GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => DocImagePreviewPage(imageUrl: lampiranUrl))); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + lampiranUrl, + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ); + } else { + return GestureDetector( + onTap: () async { + final parsedUrl = Uri.parse(lampiranUrl); + try { + await launchUrl(parsedUrl, mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tidak dapat membuka lampiran'))); + } + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.8), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.black12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 52, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + extension.length > 4 ? extension.substring(0, 4).toUpperCase() : extension.toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '${extension.toUpperCase()} • Dokumen', + style: const TextStyle( + fontSize: 12, + color: Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + }, + ); + }).toList(), + ) + ] + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/dashboard/d_guru/function/f_pesan_g/pengumuman_g.dart b/lib/dashboard/d_guru/function/f_pesan_g/pengumuman_g.dart new file mode 100644 index 0000000..54ef8c2 --- /dev/null +++ b/lib/dashboard/d_guru/function/f_pesan_g/pengumuman_g.dart @@ -0,0 +1,502 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/grup_sekolah_g.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/grup_kelas_g.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/chat_admin_g.dart'; +import 'package:apk/dashboard/d_guru/function/f_pesan_g/chat_private_g.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class PengumumanG extends StatefulWidget { + const PengumumanG({super.key}); + + @override + State createState() => _PengumumanGState(); +} + +class _PengumumanGState extends State { + bool _isLoading = true; + List> _listGuru = []; + List> _listMurid = []; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + try { + final supabase = Supabase.instance.client; + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('user_id_admin'); + final nik = prefs.getString('guru_nik'); + + if (userId == null || nik == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + // 1. Dapatkan info guru yang login + final guruData = await supabase + .from('guru') + .select('id_tabel, id_class') + .eq('user_id', userId) + .eq('nik', nik) + .maybeSingle(); + + if (guruData == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + final guruIdTabel = guruData['id_tabel']; + final guruIdClass = guruData['id_class']; + + // 2. Kumpulkan semua ID Kelas (UUID) — pola dari db_kelas + Set validClassIds = {}; + if (guruIdClass != null) { + validClassIds.add(guruIdClass.toString()); + } + + final classDataList = await supabase + .from('class_name') + .select('id_tabel, id_class') + .eq('id_guru', guruIdTabel); + + for (var c in classDataList) { + validClassIds.add(c['id_tabel'].toString()); + } + + if (validClassIds.isEmpty) { + if (mounted) { + setState(() { + _listGuru = []; + _listMurid = []; + _isLoading = false; + }); + } + return; + } + + // 3. Ambil guru yang id_class-nya ada di validClassIds + final guruInClass = await supabase + .from('guru') + .select('id_tabel, nik, name, bidang, wali, id_class') + .inFilter('id_class', validClassIds.toList()); + + // Buang diri sendiri + final List> filteredGuru = []; + for (var g in guruInClass) { + if (g['id_tabel'].toString() != guruIdTabel.toString()) { + filteredGuru.add(g); + } + } + + // 4. Ambil murid — pola dari db_kelas.getMuridForGuru() + Map> allMurid = {}; + + final muridListA = await supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class') + .inFilter('id_class', validClassIds.toList()); + + for (var m in muridListA) { + allMurid[m['id_tabel'].toString()] = m; + } + + final classMuridRows = await supabase + .from('class_name') + .select('id_murid') + .inFilter('id_tabel', validClassIds.toList()) + .not('id_murid', 'is', null); + + List muridIdsFromClass = classMuridRows + .map((r) => r['id_murid'].toString()) + .where((id) => id != 'null') + .toList(); + + if (muridIdsFromClass.isNotEmpty) { + final muridListB = await supabase + .from('murid') + .select('id_tabel, nis, nama, gender, id_class') + .inFilter('id_tabel', muridIdsFromClass); + + for (var m in muridListB) { + allMurid[m['id_tabel'].toString()] = m; + } + } + + final finalMuridList = allMurid.values.toList(); + finalMuridList.sort((a, b) => + (a['nama']?.toString() ?? '').compareTo(b['nama']?.toString() ?? '')); + + if (mounted) { + setState(() { + _listGuru = filteredGuru; + _listMurid = finalMuridList; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('Error _loadData pengumuman_g: $e'); + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 20, + title: const Text('Pengumuman Sekolah'), + titleTextStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + backgroundColor: const Color(0xFF2563EB), + elevation: 0, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + onPressed: () { + setState(() { + _isLoading = true; + }); + _loadData(); + }, + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.symmetric(vertical: 10.0), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: CustomCard( + title: 'Grup Sekolah', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Forum', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Informasi & Diskusi', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GrupSekolahG()), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: CustomCard( + title: 'Grup Kelas', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Kelas', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Diskusi Khusus Kelas Anda', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GrupKelasPageG()), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: CustomCard( + title: 'Admin Sekolah', + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + const Text( + 'Bantuan', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Flexible( + child: Text( + 'Hubungi Admin Sekolah', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ChatAdminPageG()), + ); + }, + ), + ), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text('List Guru', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, fontFamily: 'Inter')), + ), + const SizedBox(height: 10), + ..._listGuru.map((guru) { + final nama = guru['name'] ?? '-'; + final isWali = guru['wali'] == true; + final roleText = isWali ? 'Wali Kelas' : 'Guru Mapel'; + + final classNameObj = guru['class_name']; + String kelas = "Belum ada kelas"; + if (classNameObj != null) { + if (classNameObj is String) { + kelas = classNameObj; + } else if (classNameObj is List && classNameObj.isNotEmpty) { + kelas = classNameObj[0]['name_class']?.toString() ?? "Belum ada kelas"; + } else if (classNameObj is Map) { + kelas = classNameObj['name_class']?.toString() ?? "Belum ada kelas"; + } + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Row( + children: [ + Text( + roleText, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: Color(0xFFCBD5E1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + kelas, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPrivateG( + receiverId: guru['id_tabel'].toString(), + receiverType: 'guru', + receiverName: nama, + ), + ), + ); + }, + ), + ); + }).toList(), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text('List Murid', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, fontFamily: 'Inter')), + ), + const SizedBox(height: 10), + ..._listMurid.map((murid) { + final nama = murid['nama'] ?? '-'; + + final nis = murid['nis']?.toString() ?? '-'; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + child: CustomCard( + title: nama, + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + customSubtitle: Text( + 'NIS: $nis', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + showIcon: false, + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + margin: EdgeInsets.zero, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPrivateG( + receiverId: murid['id_tabel'].toString(), + receiverType: 'murid', + receiverName: nama, + ), + ), + ); + }, + ), + ); + }).toList(), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/setting_p/setting_p.dart b/lib/dashboard/d_guru/function/setting_p/setting_p.dart new file mode 100644 index 0000000..da12cb9 --- /dev/null +++ b/lib/dashboard/d_guru/function/setting_p/setting_p.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/widget/custom_button.dart'; +import 'package:apk/dashboard/d_guru/function/setting_p/update_data.dart'; + +class SettingProfilPage extends StatefulWidget { + final Map data; + + const SettingProfilPage({ + super.key, + required this.data, + }); + + @override + State createState() => _SettingProfilPageState(); +} + +class _SettingProfilPageState extends State { + + bool get _isWali => widget.data['wali'] == true; + String get _nama => widget.data['name']?.toString().isEmpty ?? true ? "Belum diisi" : widget.data['name'].toString(); + String get _nik => widget.data['nik']?.toString().isEmpty ?? true ? "Belum diisi" : widget.data['nik'].toString(); + String get _bidang => widget.data['bidang']?.toString().isEmpty ?? true ? "Belum diisi" : widget.data['bidang'].toString(); + + String get _kelasStr { + final classNameObj = widget.data['class_name']; + String kelas = ""; + if (classNameObj != null) { + if (classNameObj is String) { + kelas = classNameObj; + } else if (classNameObj is List && classNameObj.isNotEmpty) { + kelas = classNameObj[0]['name_class']?.toString() ?? ""; + } else if (classNameObj is Map) { + kelas = classNameObj['name_class']?.toString() ?? ""; + } + } + return kelas; + } + + String get _badge { + String kelas = _kelasStr; + if (_isWali) { + return kelas.isEmpty ? "Wali Kelas" : kelas; + } + return _bidang == "Belum diisi" ? "Guru" : _bidang; + } + + Widget waliField() { + return _infoCard( + icon: Icons.verified_user_outlined, + title: "Wali Kelas", + value: _isWali ? "Ya" : "Tidak", + ); + } + + Widget kelasField() { + String kelas = _kelasStr; + return _infoCard( + icon: Icons.school_outlined, + title: "Kelas", + value: kelas.isEmpty ? "Belum diisi" : kelas, + isEmpty: kelas.isEmpty, + ); + } + + Widget bidangField() { + return _infoCard( + icon: Icons.menu_book_outlined, + title: "Bidang", + value: _bidang, + isEmpty: _bidang == "Belum diisi", + ); + } + + Widget _infoCard({ + required IconData icon, + required String title, + required String value, + bool isEmpty = false, + }) { + return CustomCard( + title: title, + subtitle: value, + icon: icon, + iconPosition: IconPosition.left, + iconColor: const Color(0xFF2563EB), + iconSize: 24, + iconContainerSize: 46, + iconDecoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(9), + ), + backgroundColor: Colors.white, + borderColor: Colors.transparent, + borderWidth: 0, + borderRadius: 0, + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + boxShadow: const [], + titleStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF8FA0BA), + ), + subtitleStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + fontStyle: isEmpty ? FontStyle.italic : FontStyle.normal, + color: isEmpty ? const Color(0xFFC8D2E0) : const Color(0xFF172033), + ), + showIcon: true, + ); + } + + Widget _sectionCard(List children) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8EEF6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) + const Divider( + height: 1, + thickness: 1, + color: Color(0xFFF0F4FA), + indent: 82, + endIndent: 20, + ), + ], + ], + ), + ), + ); + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 12), + child: Text( + title, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w800, + color: Color(0xFF64748B), + ), + ), + ); + } + + Widget _profileHeader() { + return SizedBox( + height: 128, + child: Stack( + alignment: Alignment.center, + children: [ + CustomCard( + title: "", + showIcon: false, + height: 126, + backgroundColor: Colors.white, + borderColor: const Color(0xFFE8EEF6), + borderWidth: 1, + borderRadius: 18, + margin: EdgeInsets.zero, + padding: EdgeInsets.zero, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.13), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _nama, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 23, + fontWeight: FontWeight.w800, + color: Color(0xFF172033), + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.badge_outlined, + size: 18, + color: Color(0xFF8FA0BA), + ), + const SizedBox(width: 6), + Text( + "NIK: $_nik", + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ], + ), + const SizedBox(height: 18), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.school_outlined, + size: 18, + color: Color(0xFF2563EB), + ), + const SizedBox(width: 6), + Text( + _badge, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF2563EB), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + }) { + return Expanded( + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + child: Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF4F7FB), + appBar: AppBar( + title: const Text("Setting Profil", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600)), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1E293B), + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), + children: [ + _profileHeader(), + const SizedBox(height: 20), + _sectionTitle("Identitas"), + _sectionCard([ + bidangField(), + waliField(), + if (_isWali || _kelasStr.isNotEmpty) kelasField(), + ]), + const SizedBox(height: 24), + Row( + children: [ + _actionButton( + label: "Edit Profil", + color: const Color(0xFF2563EB), + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => UpdateProfilPage(data: widget.data), + ), + ); + if (result == true) { + setState(() {}); + } + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/dashboard/d_guru/function/setting_p/update_data.dart b/lib/dashboard/d_guru/function/setting_p/update_data.dart new file mode 100644 index 0000000..75dc9a0 --- /dev/null +++ b/lib/dashboard/d_guru/function/setting_p/update_data.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/d_guru/database/db_guru.dart'; + +class UpdateProfilPage extends StatefulWidget { + final Map data; + + const UpdateProfilPage({ + super.key, + required this.data, + }); + + @override + State createState() => _UpdateProfilPageState(); +} + +class _UpdateProfilPageState extends State { + bool isSaving = false; + + late TextEditingController nikC; + late TextEditingController nameC; + late TextEditingController bidangC; + + final DbGuru _dbGuru = DbGuru(); + + @override + void initState() { + super.initState(); + nikC = TextEditingController(text: widget.data['nik']?.toString() ?? ""); + nameC = TextEditingController(text: widget.data['name'] ?? ""); + bidangC = TextEditingController(text: widget.data['bidang'] ?? ""); + } + + Future updateData() async { + if (nikC.text.isEmpty || nameC.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("NIK dan Nama wajib diisi!")), + ); + return; + } + + setState(() => isSaving = true); + + try { + await _dbGuru.updateGuru( + idTabel: widget.data['id_tabel'], + nik: num.tryParse(nikC.text) ?? 0, + name: nameC.text, + bidang: bidangC.text.isEmpty ? null : bidangC.text, + idClass: widget.data['id_class'], // keep unchanged + wali: widget.data['wali'] == true, // keep unchanged + ); + + if (mounted) { + widget.data['nik'] = num.tryParse(nikC.text) ?? 0; + widget.data['name'] = nameC.text; + widget.data['bidang'] = bidangC.text; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profil berhasil diperbarui')), + ); + Navigator.pop(context, true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Gagal menyimpan: ${e.toString().replaceAll('Exception: ', '')}", + ), + ), + ); + } + } finally { + if (mounted) { + setState(() => isSaving = false); + } + } + } + + Widget field( + String label, + TextEditingController c, { + TextInputType? keyboardType, + String hint = "", + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 6), + TextField( + controller: c, + keyboardType: keyboardType, + style: const TextStyle(fontFamily: 'Inter'), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Color(0xFFC4C4C4), fontFamily: 'Inter'), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget _actionButton({ + required String label, + required Color color, + required VoidCallback? onPressed, + bool loading = false, + }) { + final bool isWhite = color == Colors.white; + return Expanded( + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: isWhite ? const Color(0xFF64748B) : Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: isWhite ? const BorderSide(color: Color(0xFF94A3B8)) : BorderSide.none, + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: isWhite ? const Color(0xFF64748B) : Colors.white, + strokeWidth: 2, + ), + ) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + @override + void dispose() { + nikC.dispose(); + nameC.dispose(); + bidangC.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text("Edit Setting Profil", style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600)), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1E293B), + elevation: 0, + ), + body: Container( + color: Colors.white, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Expanded( + child: ListView( + children: [ + Row( + children: const [ + Text("👩‍🏫", style: TextStyle(fontSize: 20)), + SizedBox(width: 8), + Text( + "Data Guru", + style: TextStyle( + fontFamily: 'Inter', + fontSize: 18, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field("NIK *", nikC, keyboardType: TextInputType.number, hint: "Contoh: 33111"), + field("Nama *", nameC, hint: "Contoh: Budi Santoso"), + field("Bidang", bidangC, hint: "Contoh: Matematika"), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + _actionButton( + label: "Simpan", + color: const Color(0xFF2563EB), + onPressed: isSaving ? null : updateData, + loading: isSaving, + ), + const SizedBox(width: 10), + _actionButton( + label: "Batal", + color: Colors.white, + onPressed: isSaving ? null : () => Navigator.pop(context), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/error_handler/connection.dart b/lib/error_handler/connection.dart new file mode 100644 index 0000000..6a35f58 --- /dev/null +++ b/lib/error_handler/connection.dart @@ -0,0 +1,69 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ConnectionHandler { + // Mengecek ketersediaan koneksi internet + static Future checkConnection() async { + if (kIsWeb) return true; + try { + final result = await InternetAddress.lookup('google.com'); + if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { + return true; + } + } on SocketException catch (_) { + return false; + } + return false; + } + + // Menampilkan pop up peringatan jika tidak ada koneksi + static void showNoConnectionDialog({ + required BuildContext context, + required VoidCallback onRetry, + }) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row( + children: [ + Icon(Icons.wifi_off, color: Colors.red, size: 28), + SizedBox(width: 8), + Text( + 'Tidak Ada Koneksi', + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ], + ), + content: const Text( + 'Pastikan perangkat Anda terhubung ke internet untuk memuat data terbaru.', + style: TextStyle(fontFamily: 'Inter', fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onRetry(); + }, + child: const Text( + 'Coba Lagi', + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.bold, + color: Color(0xFF2563EB), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 0000000..446c9bc --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,62 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: type=lint +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for web - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for ios - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.macOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for macos - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.windows: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for windows - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyDDAyv2LzeOXJfyDEysM1XvrW8AuC4d2U0', + appId: '1:1078157366732:android:cff3b9bc3759762b76f969', + messagingSenderId: '1078157366732', + projectId: 'fmc-firebasecloudmessaging', + storageBucket: 'fmc-firebasecloudmessaging.firebasestorage.app', + ); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..7f01576 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:scaled_app/scaled_app.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/firebase_options.dart'; +import 'package:apk/register/page1.dart'; +import 'package:apk/dashboard/a_admin/dashboard/class.dart'; +import 'package:apk/dashboard/a_admin/dashboard/announcement.dart'; +import 'package:apk/dashboard/a_admin/dashboard/add_person.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:apk/dashboard/a_admin/bottom_bar/main_screen.dart'; +import 'package:apk/register/admin/login_screen.dart'; +import 'package:apk/register/admin/signup_screen.dart'; +import 'package:apk/session_timer/session_time.dart'; +import 'package:apk/dashboard/a_admin/function/f_notif/notif_reddot.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart' as fcm_absen; + +double scaleFactorCallback(Size deviceSize) { + const double widthOfDesign = 375; + return deviceSize.width / widthOfDesign; +} + +Future main() async { + ScaledWidgetsFlutterBinding.ensureInitialized( + scaleFactor: scaleFactorCallback, + ); + + await dotenv.load(fileName: "lib/.env"); + + await Supabase.initialize( + url: dotenv.env['SUPABASE_URL']!, + anonKey: dotenv.env['SUPABASE_ANON_KEY']!, + ); + + // 1. Initialize Firebase FIRST dengan options yang benar (seperti apk2/apk3) + try { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + debugPrint('[Firebase] Initialized successfully'); + } catch (e) { + debugPrint('[Firebase] Initialization error: $e'); + } + + // 2. Removed Entrig Initialization + + // 3. Initialize notification listeners + try { + await NotifRedDot.init(); + await fcm_absen.NotificationFCM.init(); + } catch (_) { + // FCM tidak tersedia di platform ini (misal: Web) + } + + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + super.initState(); + _setupFCMTokenRegistration(); + } + + /// Daftarkan perangkat ke Supabase secara persisten menggunakan FCM langsung + void _setupFCMTokenRegistration() async { + await _registerStoredUserDevice(); + + // Listener Supabase Auth (khusus role Admin) + Supabase.instance.client.auth.onAuthStateChange.listen((data) async { + final session = data.session; + final prefs = await SharedPreferences.getInstance(); + final userRole = prefs.getString('user_role'); + + if (session != null && userRole == 'admin') { + final userId = session.user.id; + await fcm_absen.NotificationFCM.saveTokenToSupabase(userId, 'admin'); + } + }); + } + + Future _registerStoredUserDevice() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userRole = prefs.getString('user_role'); + + if (userRole == 'admin') { + final userId = Supabase.instance.client.auth.currentUser?.id; + if (userId != null && userId.isNotEmpty) { + await fcm_absen.NotificationFCM.saveTokenToSupabase(userId.trim(), 'admin'); + } + } else if (userRole == 'guru') { + final guruId = prefs.getString('guru_id_tabel'); + if (guruId != null && guruId.isNotEmpty) { + await fcm_absen.NotificationFCM.saveTokenToSupabase(guruId.trim(), 'guru'); + } + } else if (userRole == 'murid' || userRole == 'ortu') { + final muridId = prefs.getString('murid_id_tabel'); + + if (muridId != null && muridId.isNotEmpty) { + await fcm_absen.NotificationFCM.saveTokenToSupabase(muridId.trim(), 'murid'); + } + } + } catch (e) { + debugPrint('[FCM] Persistent token registration error: $e'); + } + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + navigatorKey: NotifRedDot.navigatorKey, + builder: (context, child) { + final originalMediaQueryData = MediaQuery.of(context).copyWith( + textScaler: TextScaler.noScaling, + ); + return SessionTimeManager( + child: MediaQuery( + data: originalMediaQueryData.scale(), + child: child ?? const SizedBox.shrink(), + ), + ); + }, + debugShowCheckedModeBanner: false, + initialRoute: '/', + routes: { + '/': (context) => const Home(), + '/load_screen/login_screen': (context) => const LoginAdmin(), + '/load_screen/signup_screen': (context) => const SignupAdmin(), + '/page1': (context) => const Home(), + '/load_screen': (context) => const MainScreen(), + '/dashboard/class': (context) => const DaftarKelas(), + '/dashboard/announcement': (context) => const Pengumuman(), + '/dashboard/add_person': (context) => const TambahPengguna(), + }, + ); + } +} \ No newline at end of file diff --git a/lib/register/admin/login_screen.dart b/lib/register/admin/login_screen.dart new file mode 100644 index 0000000..82c3129 --- /dev/null +++ b/lib/register/admin/login_screen.dart @@ -0,0 +1,282 @@ +import 'package:apk/register/admin/signup_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/a_admin/database/auth.dart'; +import 'package:apk/register/page1.dart'; + +class LoginAdmin extends StatefulWidget { + const LoginAdmin({super.key}); + + @override + State createState() => _LoginAdminState(); +} + +class _LoginAdminState extends State { + final AuthService _auth = AuthService(); + bool isHidden = true; + bool _isLoading = false; + + final TextEditingController nipController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + + void _login() async { + setState(() => _isLoading = true); + try { + final email = nipController.text.trim(); + await _auth.signIn( + email, + passwordController.text.trim(), + ); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_email', email); + await prefs.setString('user_role', 'admin'); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Login berhasil! Silakan tekan tombol Masuk."), + backgroundColor: Colors.green, + ), + ); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const Home()), + (route) => false, + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Login gagal: $e")), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 60), + children: [ + const SizedBox(height: 70), + + Align( + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF2563EB), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: const Icon( + Icons.school_outlined, + color: Colors.white, + size: 42, + ), + ), + ), + + const SizedBox(height: 28), + + const Text( + "Masuk Sebagai Admin", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + + const SizedBox(height: 8), + + const Text( + "Silakan masukkan kredensial Anda", + style: TextStyle( + fontSize: 15, + color: Color(0xFF94A3B8), + ), + ), + + const SizedBox(height: 55), + + // EMAIL + const Align( + alignment: Alignment.centerLeft, + child: Text( + "Email", + style: TextStyle( + fontSize: 16, + color: Color(0xFF334155), + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 10), + + TextField( + controller: nipController, + decoration: InputDecoration( + hintText: "Masukkan Email", + hintStyle: const TextStyle( + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + ), + ), + + const SizedBox(height: 22), + + // PASSWORD + const Align( + alignment: Alignment.centerLeft, + child: Text( + "Kata Sandi", + style: TextStyle( + fontSize: 16, + color: Color(0xFF334155), + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 10), + + TextField( + controller: passwordController, + obscureText: isHidden, + decoration: InputDecoration( + hintText: "Masukkan kata sandi", + hintStyle: const TextStyle( + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + suffixIcon: IconButton( + icon: Icon( + isHidden + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: const Color(0xFF94A3B8), + ), + onPressed: () { + setState(() { + isHidden = !isHidden; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + ), + ), + + const SizedBox(height: 40), + + // BUTTON + SizedBox( + width: double.infinity, + height: 58, + child: ElevatedButton( + onPressed: _isLoading ? null : _login, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), + elevation: 8, + shadowColor: Colors.green.withOpacity(0.35), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: _isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + "Masuk", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + + const SizedBox(height: 24), + + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Belum punya akun? ", + style: TextStyle( + color: Color(0xFF64748B), + fontSize: 15, + ), + ), + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SignupAdmin()), + ); + }, + child: const Text( + "Daftar", + style: TextStyle( + color: Color(0xFF2563EB), + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + + const SizedBox(height: 70), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/register/admin/signup_screen.dart b/lib/register/admin/signup_screen.dart new file mode 100644 index 0000000..e695292 --- /dev/null +++ b/lib/register/admin/signup_screen.dart @@ -0,0 +1,422 @@ +import 'dart:async'; +import 'package:apk/register/admin/login_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:apk/dashboard/a_admin/database/auth.dart'; + +class SignupAdmin extends StatefulWidget { + const SignupAdmin({super.key}); + + @override + State createState() => _SignupAdminState(); +} + +class _SignupAdminState extends State { + final _auth = AuthService(); + + final emailController = TextEditingController(); + final passwordController = TextEditingController(); + final otpController = TextEditingController(); + + bool isHidden = true; + bool isLoading = false; + bool isOtpSent = false; + + Timer? timer; + int secondsLeft = 300; // 5 menit + + /// =============================== + /// KIRIM OTP + /// =============================== + Future sendOtp() async { + setState(() => isLoading = true); + + try { + final email = emailController.text.trim(); + final password = passwordController.text.trim(); + + await _auth.signUp(email, password); + + startTimer(); + + setState(() { + isOtpSent = true; + isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("OTP dikirim ke email")), + ); + } catch (e) { + setState(() => isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error: $e")), + ); + } + } + + /// =============================== + /// TIMER 5 MENIT + /// =============================== + void startTimer() { + timer?.cancel(); + secondsLeft = 300; + + timer = Timer.periodic(const Duration(seconds: 1), (t) async { + if (secondsLeft == 0) { + t.cancel(); + + /// hapus user public (jika ada) + await _auth.deleteUserPublic(emailController.text.trim()); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Waktu habis, silakan daftar ulang")), + ); + + setState(() { + isOtpSent = false; + otpController.clear(); + }); + + } else { + setState(() => secondsLeft--); + } + }); + } + + /// =============================== + /// VERIFY OTP + /// =============================== + Future verifyOtp() async { + setState(() => isLoading = true); + + try { + final email = emailController.text.trim(); + final otp = otpController.text.trim(); + + final res = await _auth.verifyOtp(email, otp); + final user = res.user; + + if (user != null) { + /// STOP TIMER + timer?.cancel(); + + /// INSERT SETELAH OTP VALID + await _auth.insertUser( + id: user.id, + email: email, + ); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Verifikasi berhasil")), + ); + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const LoginAdmin(), + ), + ); + } + } catch (e) { + setState(() => isLoading = false); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("OTP salah")), + ); + } + } + + String formatTime(int seconds) { + final m = (seconds ~/ 60).toString().padLeft(2, '0'); + final s = (seconds % 60).toString().padLeft(2, '0'); + return "$m:$s"; + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 60), + children: [ + const SizedBox(height: 70), + + Align( + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF2563EB), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: const Icon( + Icons.school_outlined, + color: Colors.white, + size: 42, + ), + ), + ), + + const SizedBox(height: 28), + + const Text( + "Daftar Akun Admin", + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + + const SizedBox(height: 8), + + const Text( + "Silakan masukkan kredensial Anda", + style: TextStyle( + fontSize: 15, + color: Color(0xFF94A3B8), + ), + ), + + const SizedBox(height: 55), + + // EMAIL + const Align( + alignment: Alignment.centerLeft, + child: Text( + "Email", + style: TextStyle( + fontSize: 16, + color: Color(0xFF334155), + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 10), + + /// EMAIL + TextField( + controller: emailController, + decoration: InputDecoration( + hintText: "Masukkan Email", + hintStyle: const TextStyle( + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + ), + ), + + const SizedBox(height: 22), + + // PASSWORD + const Align( + alignment: Alignment.centerLeft, + child: Text( + "Kata Sandi", + style: TextStyle( + fontSize: 16, + color: Color(0xFF334155), + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 10), + + TextField( + controller: passwordController, + obscureText: isHidden, + decoration: InputDecoration( + hintText: "Masukkan kata sandi", + hintStyle: const TextStyle( + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + suffixIcon: IconButton( + icon: Icon( + isHidden + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: const Color(0xFF94A3B8), + ), + onPressed: () { + setState(() { + isHidden = !isHidden; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + ), + ), + + /// OTP FIELD + if (isOtpSent) ...[ + const SizedBox(height: 22), + const Align( + alignment: Alignment.centerLeft, + child: Text( + "Kode OTP", + style: TextStyle( + fontSize: 16, + color: Color(0xFF334155), + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 10), + + TextField( + controller: otpController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: "Masukkan OTP", + hintStyle: const TextStyle( + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + ), + ), + const SizedBox(height: 10), + + Text( + "Waktu: ${formatTime(secondsLeft)}", + style: const TextStyle(color: Color(0xFF94A3B8)), + ), + ], + + const SizedBox(height: 40), + + /// BUTTON + SizedBox( + width: double.infinity, + height: 58, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), + elevation: 8, + shadowColor: Colors.green.withOpacity(0.35), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + onPressed: isLoading + ? null + : isOtpSent + ? verifyOtp + : sendOtp, + child: isLoading + ? const CircularProgressIndicator(color: Colors.white) + : Text(isOtpSent ? "Verifikasi" : "Daftar", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + + const SizedBox(height: 24), + + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Sudah punya akun? ", + style: TextStyle( + color: Color(0xFF64748B), + fontSize: 15, + ), + ), + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LoginAdmin(), + ), + ); + }, + child: const Text( + "Masuk", + style: TextStyle( + color: Color(0xFF2563EB), + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + + const SizedBox(height: 70), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/register/guru/login_guru.dart b/lib/register/guru/login_guru.dart new file mode 100644 index 0000000..3be436b --- /dev/null +++ b/lib/register/guru/login_guru.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart' as fcm_absen; +import 'package:apk/dashboard/d_guru/database/auth_guru.dart'; +import 'package:apk/register/page1.dart'; + +class UserGuru extends StatefulWidget { + const UserGuru({super.key}); + + @override + State createState() => _UserGuruState(); +} + +class _UserGuruState extends State { + final AuthGuru _auth = AuthGuru(); + bool _isLoading = false; + + final TextEditingController userIdController = TextEditingController(); + final TextEditingController nipController = TextEditingController(); + + void _login() async { + setState(() => _isLoading = true); + try { + final emailAdmin = userIdController.text.trim(); + final nip = nipController.text.trim(); + final result = await _auth.signInGuru(emailAdmin, nip); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_email', emailAdmin); + await prefs.setString('user_role', 'guru'); + + // Daftarkan token FCM ke Supabase + final guruId = result['id_tabel']?.toString(); + if (guruId != null && guruId.isNotEmpty) { + await fcm_absen.NotificationFCM.saveTokenToSupabase(guruId.trim(), 'guru'); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Login berhasil! Silakan tekan tombol Masuk."), + backgroundColor: Colors.green, + ), + ); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const Home()), + (route) => false, + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Login gagal: $e")), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: ListView( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 0,), + children: [ + const SizedBox(height: 70), + + Align( + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF2563EB), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: const Icon( + Icons.school_outlined, + color: Colors.white, + size: 42, + ), + ), + ), + + const SizedBox(height: 28), + + const Text( + "Masuk Sebagai Guru", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + + const SizedBox(height: 8), + + const Text( + "Silakan masukkan kredensial Anda", + style: TextStyle( + fontSize: 15, + color: Color(0xFF94A3B8), + ), + ), + + const SizedBox(height: 55), + + // EMAIL + const Align( + alignment: Alignment.centerLeft, + child: Text( + "Email Admin", + style: TextStyle( + fontSize: 16, + color: Color(0xFF334155), + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 10), + + TextField( + controller: userIdController, + decoration: InputDecoration( + hintText: "Masukkan Email Admin", + hintStyle: const TextStyle( + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + ), + ), + + const SizedBox(height: 22), + + // PASSWORD + const Align( + alignment: Alignment.centerLeft, + child: Text( + "NIP", + style: TextStyle( + fontSize: 16, + color: Color(0xFF334155), + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 10), + + TextField( + controller: nipController, + decoration: InputDecoration( + hintText: "Masukkan NIP", + hintStyle: const TextStyle( + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + ), + ), + + const SizedBox(height: 40), + + // BUTTON + Padding( + padding: const EdgeInsets.symmetric(horizontal: 60), + child: SizedBox( + width: double.infinity, + height: 58, + child: ElevatedButton( + onPressed: _isLoading ? null : _login, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), + elevation: 8, + shadowColor: Colors.green.withOpacity(0.35), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: _isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + "Masuk", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/register/murid/login_murid.dart b/lib/register/murid/login_murid.dart new file mode 100644 index 0000000..b58f93b --- /dev/null +++ b/lib/register/murid/login_murid.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/dashboard/d_c_murid/function/FCM_absen.dart/fcm_notif.dart' as fcm_absen; +import 'package:apk/dashboard/d_c_murid/database/auth_ortu.dart'; +import 'package:apk/register/page1.dart'; + +class UserMurid extends StatefulWidget { + const UserMurid({super.key}); + + @override + State createState() => _UserMuridState(); +} + +class _UserMuridState extends State { + final AuthOrtu _auth = AuthOrtu(); + bool _isLoading = false; + + final TextEditingController userIdController = TextEditingController(); + final TextEditingController nisController = TextEditingController(); + + void _login() async { + setState(() => _isLoading = true); + try { + final emailAdmin = userIdController.text.trim(); + final nis = nisController.text.trim(); + final result = await _auth.signInOrtu(emailAdmin, nis); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_email', emailAdmin); + await prefs.setString('user_role', 'murid'); + // Daftarkan token FCM ke Supabase + final muridId = result['id_tabel']?.toString(); + + if (muridId != null && muridId.isNotEmpty) { + await fcm_absen.NotificationFCM.saveTokenToSupabase(muridId.trim(), 'murid'); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Login berhasil! Silakan tekan tombol Masuk."), + backgroundColor: Colors.green, + ), + ); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const Home()), + (route) => false, + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Login gagal: $e")), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: ListView( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 0,), + children: [ + const SizedBox(height: 70), + + Align( + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF2563EB), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: const Icon( + Icons.school_outlined, + color: Colors.white, + size: 42, + ), + ), + ), + + const SizedBox(height: 28), + + const Text( + "Masuk Sebagai Murid", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF1E293B), + ), + ), + + const SizedBox(height: 8), + + const Text( + "Silakan masukkan kredensial Anda", + style: TextStyle( + fontSize: 15, + color: Color(0xFF94A3B8), + ), + ), + + const SizedBox(height: 55), + + // EMAIL + const Align( + alignment: Alignment.centerLeft, + child: Text( + "Email Admin", + style: TextStyle( + fontSize: 16, + color: Color(0xFF334155), + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 10), + + TextField( + controller: userIdController, + decoration: InputDecoration( + hintText: "Masukkan Email Admin", + hintStyle: const TextStyle( + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + ), + ), + + const SizedBox(height: 22), + + // PASSWORD + const Align( + alignment: Alignment.centerLeft, + child: Text( + "NIS", + style: TextStyle( + fontSize: 16, + color: Color(0xFF334155), + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 10), + + TextField( + controller: nisController, + decoration: InputDecoration( + hintText: "Masukkan NIS", + hintStyle: const TextStyle( + color: Color(0xFFC4C4C4), + ), + filled: true, + fillColor: const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFE2E8F0), + ), + ), + ), + ), + + const SizedBox(height: 40), + + // BUTTON + Padding( + padding: const EdgeInsets.symmetric(horizontal: 60), + child: SizedBox( + width: double.infinity, + height: 58, + child: ElevatedButton( + onPressed: _isLoading ? null : _login, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), + elevation: 8, + shadowColor: Colors.green.withOpacity(0.35), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: _isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + "Masuk", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ), + + ], + ), + ); + } +} diff --git a/lib/register/option_user.dart b/lib/register/option_user.dart new file mode 100644 index 0000000..0e3c036 --- /dev/null +++ b/lib/register/option_user.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:apk/register/admin/login_screen.dart'; +import 'package:apk/register/guru/login_guru.dart'; +import 'package:apk/register/murid/login_murid.dart'; + +class OptionUser extends StatelessWidget { + const OptionUser({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // Top Bar with back button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_rounded, color: Color(0xFF1E293B)), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 24), + children: [ + const SizedBox(height: 10), + + // Title + const Text( + 'Pilih Peran', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 26, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 6), + const Text( + 'Pilih jenis akun untuk masuk ke aplikasi.', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFF64748B), + ), + ), + + const SizedBox(height: 24), + + // 1. Admin Option Card + _buildRoleTile( + context: context, + icon: Icons.admin_panel_settings_rounded, + iconBgColor: const Color(0xFFEFF6FF), + iconColor: const Color(0xFF2563EB), + title: 'Admin Sekolah', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginAdmin()), + ); + }, + ), + + const SizedBox(height: 14), + + // 2. Guru Option Card + _buildRoleTile( + context: context, + icon: Icons.school_rounded, + iconBgColor: const Color(0xFFF0FDF4), + iconColor: const Color(0xFF16A34A), + title: 'Guru / Pengajar', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const UserGuru()), + ); + }, + ), + + const SizedBox(height: 14), + + // 3. Murid Option Card + _buildRoleTile( + context: context, + icon: Icons.face_rounded, + iconBgColor: const Color(0xFFFAF5FF), + iconColor: const Color(0xFF9333EA), + title: 'Murid / Orang Tua', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const UserMurid()), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildRoleTile({ + required BuildContext context, + required IconData icon, + required Color iconBgColor, + required Color iconColor, + required String title, + required VoidCallback onTap, + }) { + return Container( + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: iconBgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: iconColor, size: 26), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFF0F172A), + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Color(0xFF94A3B8), + size: 22, + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/register/page1.dart b/lib/register/page1.dart new file mode 100644 index 0000000..042c709 --- /dev/null +++ b/lib/register/page1.dart @@ -0,0 +1,203 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:apk/register/option_user.dart'; +import 'package:apk/dashboard/a_admin/bottom_bar/main_screen.dart'; +import 'package:apk/dashboard/d_guru/bottom_bar/nav_screen_g.dart'; +import 'package:apk/dashboard/d_c_murid/bottom_bar/nav_screen_o.dart'; + +class Home extends StatefulWidget { + const Home({super.key}); + + @override + State createState() => _LoginState(); +} + +class _LoginState extends State { + late Timer _timer; + String _time = ""; + + @override + void initState() { + super.initState(); + _updateTime(); + + _timer = Timer.periodic(const Duration(minutes: 1), (timer) { + _updateTime(); + }); + } + + void _updateTime() { + final now = DateTime.now(); + setState(() { + _time = + "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}"; + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + void _login() async { + final prefs = await SharedPreferences.getInstance(); + final userEmail = prefs.getString('user_email'); + final userRole = prefs.getString('user_role'); + + if (!mounted) return; + + if (userEmail != null && userEmail.isNotEmpty) { + if (userRole == 'guru') { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const NavScreenG()), + (route) => false, + ); + } else if (userRole == 'murid' || userRole == 'ortu') { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const NavScreenM()), + (route) => false, + ); + } else { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const MainScreen()), + (route) => false, + ); + } + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Anda belum login. Silakan tekan tombol gembok 🔒 untuk login terlebih dahulu.'), + backgroundColor: Colors.redAccent, + duration: Duration(seconds: 3), + ), + ); + } + } + + void _loginWithLock() { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const OptionUser()), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + bottomSheet: Container( + color: Colors.white, + height: 60, + child: const Center( + child: Text( + 'Powered by Yourself', + style: TextStyle( + fontFamily: "Inter", + color: Color(0xFF94A3B8), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + body: Stack( + children: [ + Positioned( + top: 50, + right: 60, + child: Text( + _time, + style: const TextStyle( + fontFamily: 'Digital-7', + fontSize: 60, + color: Colors.black, + letterSpacing: 2, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 60, right: 60, bottom: 100), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Align( + alignment: Alignment.centerLeft, + child: Text( + 'Kelola semuanya\nlebih mudah & cepat', + style: TextStyle( + fontFamily: "Inter", + fontSize: 18, + color: Colors.black, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 30), + + // ===== BUTTON ROW ===== + Row( + children: [ + // Tombol utama + Expanded( + child: SizedBox( + height: 58, + child: ElevatedButton( + onPressed: _login, + style: ElevatedButton.styleFrom( + backgroundColor: + const Color.fromARGB(255, 15, 0, 221), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + 'Masuk', + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + + const SizedBox(width: 10), + + // Tombol icon kotak + SizedBox( + width: 58, + height: 58, + child: ElevatedButton( + onPressed: _loginWithLock, + style: ElevatedButton.styleFrom( + backgroundColor: + const Color.fromARGB(255, 15, 0, 221), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: EdgeInsets.zero, + ), + child: const Icon( + Icons.lock, + color: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/session_timer/session_time.dart b/lib/session_timer/session_time.dart new file mode 100644 index 0000000..47b836f --- /dev/null +++ b/lib/session_timer/session_time.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class SessionTimeManager extends StatefulWidget { + final Widget child; + + const SessionTimeManager({super.key, required this.child}); + + @override + State createState() => _SessionTimeManagerState(); +} + +class _SessionTimeManagerState extends State with WidgetsBindingObserver { + Timer? _bgTimer; + // Waktu timeout: 15 menit + final int _timeoutMinutes = 15; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _bgTimer?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + if (state == AppLifecycleState.paused) { + // Aplikasi masuk ke background + _startBackgroundTimer(); + } else if (state == AppLifecycleState.resumed) { + // Aplikasi kembali dibuka (foreground) + _cancelBackgroundTimer(); + } + } + + void _startBackgroundTimer() { + _bgTimer?.cancel(); + _bgTimer = Timer(Duration(minutes: _timeoutMinutes), () { + // Menutup aplikasi secara paksa setelah 15 menit tidak aktif di background + SystemNavigator.pop(); + }); + } + + void _cancelBackgroundTimer() { + // Membatalkan timer jika pengguna kembali membuka aplikasi sebelum 15 menit + _bgTimer?.cancel(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/lib/ui_custom/custom_button.dart b/lib/ui_custom/custom_button.dart new file mode 100644 index 0000000..64d3a6d --- /dev/null +++ b/lib/ui_custom/custom_button.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; + +enum IconPosition { + left, + right, +} + +class CustomCard extends StatelessWidget { + final String title; + final String? subtitle; + final Widget? customSubtitle; + + // SIZE + final double? width; + final double? height; + + // ACTION + final VoidCallback? onTap; + + // CARD ALIGNMENT + final AlignmentGeometry alignment; + + // STYLE + final Color backgroundColor; + final Color borderColor; + final double borderWidth; + final double borderRadius; + + // TEXT STYLE + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; + + // TEXT DEFAULT + final TextAlign titleAlign; + final TextAlign subtitleAlign; + final int? titleMaxLines; + final int? subtitleMaxLines; + final TextOverflow? titleOverflow; + final TextOverflow? subtitleOverflow; + + // ICON + final IconData? icon; + final Color iconColor; + final double iconSize; + final bool showIcon; + + // SPACING + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry margin; + + // SHADOW + final List? boxShadow; + + final Widget? customIcon; + + final IconPosition iconPosition; + final BoxDecoration? iconDecoration; + final Gradient? iconGradient; + final EdgeInsetsGeometry iconPadding; + final double iconContainerSize; + final Gradient? gradient; + + const CustomCard({ + super.key, + required this.title, + this.subtitle, + this.customSubtitle, + + this.width, + this.height, + this.onTap, + + this.alignment = Alignment.centerLeft, + + this.backgroundColor = const Color(0xFFF1F5F9), + this.borderColor = const Color(0xFFBFDBFE), + this.borderWidth = 1, + this.borderRadius = 16, + + this.titleStyle, + this.subtitleStyle, + + //TEXT DEFAULT + this.titleAlign = TextAlign.left, + this.subtitleAlign = TextAlign.left, + this.titleMaxLines, + this.subtitleMaxLines, + this.titleOverflow, + this.subtitleOverflow, + + this.icon = Icons.chevron_right, + this.iconColor = const Color(0xFF94A3B8), + this.iconSize = 24, + this.showIcon = true, + + this.padding = const EdgeInsets.all(16), + this.margin = const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + + this.boxShadow, + + this.customIcon, + + this.iconPosition = IconPosition.right, + this.iconDecoration, + this.iconGradient, + this.iconPadding = const EdgeInsets.all(8), + this.iconContainerSize = 40, + this.gradient, + }); + + Widget _buildIcon() { + if (!showIcon) return const SizedBox(); + + Widget iconWidget = customIcon ?? + (icon != null + ? Icon( + icon, + color: iconColor, + size: iconSize, + ) + : const SizedBox()); + + return Container( + width: iconContainerSize, + height: iconContainerSize, + padding: iconPadding, + decoration: iconDecoration ?? + BoxDecoration( + gradient: iconGradient, + borderRadius: BorderRadius.circular(12), + ), + child: Center(child: iconWidget), + ); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: alignment, + child: InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onTap, + child: Container( + width: width ?? double.infinity, + height: height, + margin: margin, + padding: padding, + decoration: BoxDecoration( + color: gradient == null ? backgroundColor : null, + gradient: gradient, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: borderColor, + width: borderWidth, + ), + boxShadow: boxShadow ?? + [ + BoxShadow( + // ignore: deprecated_member_use + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // ICON + if (iconPosition == IconPosition.left) ...[ + _buildIcon(), + const SizedBox(width: 8), + ], + + // TEXT + Expanded( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + + // 🔥 ini ikut textAlign + crossAxisAlignment: titleAlign == TextAlign.right + ? CrossAxisAlignment.end + : titleAlign == TextAlign.center + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + + children: [ + Text( + title, + textAlign: titleAlign, + maxLines: titleMaxLines, + overflow: titleOverflow, + style: titleStyle ?? + const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + ), + if (customSubtitle != null) ...[ + const SizedBox(height: 4), + customSubtitle!, + ] else if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + textAlign: subtitleAlign, + maxLines: subtitleMaxLines, + overflow: subtitleOverflow, + style: subtitleStyle ?? + const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + color: Color(0xFF64748B), + ), + ), + ], + ], + ), + ), + ), + if (iconPosition == IconPosition.right) ...[ + const SizedBox(width: 8), + _buildIcon(), + ], + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui_custom/custom_textfield.dart b/lib/ui_custom/custom_textfield.dart new file mode 100644 index 0000000..4d3053b --- /dev/null +++ b/lib/ui_custom/custom_textfield.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +class CustomTextField extends StatefulWidget { + final TextEditingController controller; + final String label; + final String? hint; + final IconData? prefixIcon; + final Widget? suffixIcon; + + final bool isPassword; + final bool readOnly; + final bool enabled; + final TextInputType keyboardType; + final int maxLines; + + final String? Function(String?)? validator; + final void Function(String)? onChanged; + final VoidCallback? onTap; + + final Color borderColor; + final Color focusColor; + + const CustomTextField({ + super.key, + required this.controller, + required this.label, + this.hint, + this.prefixIcon, + this.suffixIcon, + this.isPassword = false, + this.readOnly = false, + this.enabled = true, + this.keyboardType = TextInputType.text, + this.maxLines = 1, + this.validator, + this.onChanged, + this.onTap, + this.borderColor = Colors.grey, + this.focusColor = Colors.blue, + }); + + @override + State createState() => _CustomTextFieldState(); +} + +class _CustomTextFieldState extends State { + bool obscureText = true; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: widget.controller, + obscureText: widget.isPassword ? obscureText : false, + readOnly: widget.readOnly, + enabled: widget.enabled, + keyboardType: widget.keyboardType, + maxLines: widget.isPassword ? 1 : widget.maxLines, + validator: widget.validator, + onChanged: widget.onChanged, + onTap: widget.onTap, + + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hint, + + prefixIcon: widget.prefixIcon != null + ? Icon(widget.prefixIcon) + : null, + + suffixIcon: widget.isPassword + ? IconButton( + icon: Icon( + obscureText + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + obscureText = !obscureText; + }); + }, + ) + : widget.suffixIcon, + + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: widget.borderColor), + ), + + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: widget.borderColor), + ), + + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: widget.focusColor, width: 2), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui_custom/time_helper.dart b/lib/ui_custom/time_helper.dart new file mode 100644 index 0000000..2473897 --- /dev/null +++ b/lib/ui_custom/time_helper.dart @@ -0,0 +1,19 @@ +import 'package:intl/intl.dart'; + +class TimeHelper { + static DateTime now() { + return DateTime.now(); + } + + static String getTime() { + return DateFormat('HH:mm').format(DateTime.now()); + } + + static String getDate() { + return DateFormat('dd-MM-yyyy').format(DateTime.now()); + } + + static String getDateTime() { + return DateFormat('dd MMM yyyy, HH:mm:ss').format(DateTime.now()); + } +} \ No newline at end of file diff --git a/lib/ui_custom/ui_jam.dart b/lib/ui_custom/ui_jam.dart new file mode 100644 index 0000000..e69de29 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..18f4fb3 --- /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 "apk_full") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.apk_full") + +# 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..e12c657 --- /dev/null +++ b/linux/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 + +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) 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..2127acd --- /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 + gtk + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +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..0026732 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#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) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// 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, "apk_full"); + 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, "apk_full"); + } + + gtk_window_set_default_size(window, 1280, 720); + + 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); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(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..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#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..00024d5 --- /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_picker +import file_selector_macos +import firebase_core +import firebase_messaging +import share_plus +import shared_preferences_foundation +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + 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..fb0be7b --- /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 /* apk_full.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "apk_full.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 /* apk_full.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 /* apk_full.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.apkFull.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/apk_full.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/apk_full"; + }; + 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.apkFull.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/apk_full.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/apk_full"; + }; + 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.apkFull.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/apk_full.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/apk_full"; + }; + 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.15; + 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.15; + 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.15; + 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..a6311c5 --- /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..0a838ab --- /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 = apk_full + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.apkFull + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 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..f4f6e01 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1114 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c" + url: "https://pub.dev" + source: hosted + version: "1.3.75" + app_links: + dependency: transitive + description: + name: app_links + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" + url: "https://pub.dev" + source: hosted + version: "7.0.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" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe + url: "https://pub.dev" + source: hosted + version: "3.4.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + excel: + dependency: "direct main" + description: + name: excel + sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780" + url: "https://pub.dev" + source: hosted + version: "4.0.6" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://pub.dev" + source: hosted + version: "11.0.2" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9" + url: "https://pub.dev" + source: hosted + version: "4.12.1" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938 + url: "https://pub.dev" + source: hosted + version: "8.0.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555 + url: "https://pub.dev" + source: hosted + version: "3.9.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556" + url: "https://pub.dev" + source: hosted + version: "16.4.3" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63" + url: "https://pub.dev" + source: hosted + version: "4.9.2" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + 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_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 + url: "https://pub.dev" + source: hosted + version: "6.0.1" + flutter_launcher_icons: + dependency: "direct main" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + 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: e9685e9ab852a8b8e0579c867f6c9155da27317b294b3df796a536b8b8e0d253 + url: "https://pub.dev" + source: hosted + version: "2.6.4" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: "360bba9606e58d5acc59a033ab64ae3cf571a6868f7a7267cb8e9a204b7bf1b2" + url: "https://pub.dev" + source: hosted + version: "2.26.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + 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: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + 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: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + passkeys_platform_interface: + dependency: transitive + description: + name: passkeys_platform_interface + sha256: e810520c7b79dca629fdc266958564eedd77dc85a955f5e94430dfccb92eef68 + url: "https://pub.dev" + source: hosted + version: "2.9.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + 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" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: "10e3f195e131eec944fa872644aed89b33b5be998f3fc77c87325db3927bc646" + url: "https://pub.dev" + source: hosted + version: "2.8.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "0cfbe0047e2591eba348938e9b4e620d5b1a8df6c374757e8eb8645252b8387f" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + 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" + scaled_app: + dependency: "direct main" + description: + name: scaled_app + sha256: e56145496f51d1f14cb48d8dfd71795ed37a2adabd5228dd21dad20be1914e69 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + screenshot: + dependency: "direct main" + description: + name: screenshot + sha256: "63817697a7835e6ce82add4228e15d233b74d42975c143ad8cfe07009fab866b" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + 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: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + 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: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + 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: "221263cfbe0c01575b7d7fe7543e44abff380eb4d38d936372844a1caa85ba12" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + 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: "3879996256325fcc9abb03b62b12c07e4eaae2e008e1bf043cc1525184159a43" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: c9916c1cd512ebf3107ec0f83c9dca13d2e1e789629c2a5df896586bff46ce5f + url: "https://pub.dev" + source: hosted + version: "2.16.0" + 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: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + 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: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + 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: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" +sdks: + dart: ">=3.11.4 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..00b5e1e --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,101 @@ +name: apk +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.11.4 + +# 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 + supabase_flutter: ^2.12.2 + intl: ^0.20.2 + flutter_dotenv: ^6.0.0 + shared_preferences: ^2.5.5 + image_picker: ^1.2.1 + file_picker: ^11.0.2 + url_launcher: ^6.3.2 + scaled_app: ^2.3.1 + flutter_launcher_icons: ^0.14.4 + http: ^1.6.0 + firebase_core: ^4.10.0 + firebase_messaging: ^16.4.3 + excel: ^4.0.6 + path_provider: ^2.1.4 + share_plus: ^10.1.4 + screenshot: ^3.0.0 + pdf: ^3.12.0 + + +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: ^6.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: + - lib/assets/icons/ + - lib/.env + + fonts: + - family: Inter + fonts: + - asset: lib/assets/fonts/Inter_18pt-Regular.ttf + weight: 400 + - asset: lib/assets/fonts/Inter_18pt-Medium.ttf + weight: 500 + - asset: lib/assets/fonts/Inter_18pt-SemiBold.ttf + weight: 600 + - asset: lib/assets/fonts/Inter_18pt-Bold.ttf + weight: 700 + - family: Digital-7 + fonts: + - asset: lib/assets/fonts/DS-DIGI.ttf + + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package + diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest new file mode 100644 index 0000000..8403edf --- /dev/null +++ b/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.109.1 \ No newline at end of file diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..4201b59 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,425 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "apk_full" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# [experimental.pgdelta] +# When enabled, pg-delta becomes the active engine for supported schema flows. +# enabled = false +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + +[functions.delete-unverified-users] +enabled = true +verify_jwt = true +import_map = "./functions/delete-unverified-users/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/delete-unverified-users/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/delete-unverified-users/*.html" ] + +[functions.send-absen-fcm] +enabled = true +verify_jwt = true +import_map = "./functions/send-absen-fcm/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/send-absen-fcm/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/send-absen-fcm/*.html" ] diff --git a/supabase/functions/delete-unverified-users/.npmrc b/supabase/functions/delete-unverified-users/.npmrc new file mode 100644 index 0000000..48c6388 --- /dev/null +++ b/supabase/functions/delete-unverified-users/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/supabase/functions/delete-unverified-users/deno.json b/supabase/functions/delete-unverified-users/deno.json new file mode 100644 index 0000000..758d070 --- /dev/null +++ b/supabase/functions/delete-unverified-users/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2" + } +} diff --git a/supabase/functions/delete-unverified-users/index.ts b/supabase/functions/delete-unverified-users/index.ts new file mode 100644 index 0000000..e2269d5 --- /dev/null +++ b/supabase/functions/delete-unverified-users/index.ts @@ -0,0 +1,40 @@ +import "@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +Deno.serve(async () => { + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + const fiveMinutesAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const { data, error } = await supabase.auth.admin.listUsers(); + + if (error) { + return new Response( + JSON.stringify({ error: error.message }), + { status: 500 }, + ); + } + + const users = data.users; + + const targetUsers = users.filter((user) => { + return ( + !user.email_confirmed_at && + new Date(user.created_at) < fiveMinutesAgo + ); + }); + + for (const user of targetUsers) { + await supabase.auth.admin.deleteUser(user.id); + } + + return new Response( + JSON.stringify({ + deleted: targetUsers.length, + }), + { headers: { "Content-Type": "application/json" } }, + ); +}); diff --git a/supabase/functions/send-absen-fcm/.npmrc b/supabase/functions/send-absen-fcm/.npmrc new file mode 100644 index 0000000..48c6388 --- /dev/null +++ b/supabase/functions/send-absen-fcm/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/supabase/functions/send-absen-fcm/deno.json b/supabase/functions/send-absen-fcm/deno.json new file mode 100644 index 0000000..758d070 --- /dev/null +++ b/supabase/functions/send-absen-fcm/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2" + } +} diff --git a/supabase/functions/send-absen-fcm/index.ts b/supabase/functions/send-absen-fcm/index.ts new file mode 100644 index 0000000..40284e3 --- /dev/null +++ b/supabase/functions/send-absen-fcm/index.ts @@ -0,0 +1,122 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.7.1" +import { GoogleAuth } from "https://esm.sh/google-auth-library@8.7.0" + +serve(async (req) => { + try { + const payload = await req.json() + console.log("Webhook Payload:", payload) + + const record = payload.record + if (!record) throw new Error("No record found in payload") + + const supabaseUrl = Deno.env.get("SUPABASE_URL") + const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") + if (!supabaseUrl || !supabaseServiceKey) throw new Error("Supabase credentials not set") + + const supabase = createClient(supabaseUrl, supabaseServiceKey) + + let muridId = record.id_murid + let namaUser = record.nama_user || "Siswa" + + if (!muridId) { + if (record.rfid) { + const { data: muridData } = await supabase.from("murid").select("id_tabel").eq("rfid", record.rfid).single() + if (muridData) muridId = muridData.id_tabel + } else { + const { data: muridData } = await supabase.from("murid").select("id_tabel").eq("nama", namaUser).single() + if (muridData) muridId = muridData.id_tabel + } + } + + let targetUserId = muridId; + + if (!targetUserId && namaUser && namaUser !== "Siswa") { + const { data: guruData } = await supabase.from("guru").select("id_tabel").eq("name", namaUser).maybeSingle() + if (guruData) targetUserId = guruData.id_tabel + } + + if (!targetUserId) { + return new Response(JSON.stringify({ message: "No target user to notify" }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }) + } + + const query = supabase.from("fcm_tokens").select("token").eq("user_id", targetUserId) + + const { data: tokensData, error: tokenError } = await query + if (tokenError) throw tokenError + + const tokens = tokensData.map(t => t.token) + if (tokens.length === 0) { + return new Response(JSON.stringify({ message: "No devices to notify" }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }) + } + + console.log(`Found ${tokens.length} tokens to notify.`) + + const fcmServiceAccountStr = Deno.env.get("FIREBASE_SERVICE_ACCOUNT") + if (!fcmServiceAccountStr) throw new Error("FIREBASE_SERVICE_ACCOUNT secret is missing") + + const serviceAccount = JSON.parse(fcmServiceAccountStr) + + const auth = new GoogleAuth({ + credentials: { + client_email: serviceAccount.client_email, + private_key: serviceAccount.private_key, + }, + scopes: ["https://www.googleapis.com/auth/firebase.messaging"], + }) + + const accessToken = await auth.getAccessToken() + + const message = { + message: { + notification: { + title: "Aplikasi Absensi", + body: `${namaUser} telah melakukan absensi.` + }, + data: { + id_murid: muridId || "", + nama_user: namaUser, + id_tabel: record.id_tabel || "", + type: "absen" + } + } + } + + const projectId = serviceAccount.project_id + const fcmUrl = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send` + + const promises = tokens.map(async (token) => { + const msg = { ...message, message: { ...message.message, token: token } } + const res = await fetch(fcmUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(msg), + }) + if (!res.ok) { + console.error(`Failed to send to ${token}:`, await res.text()) + } + }) + + await Promise.all(promises) + + return new Response(JSON.stringify({ success: true, notified: tokens.length }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }) + } catch (error) { + console.error("Error:", error) + return new Response(JSON.stringify({ error: error.message }), { + headers: { "Content-Type": "application/json" }, + status: 500, + }) + } +}) diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..9b7fbd6 --- /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:apk/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..6f82fbc --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + apk_full + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..8f111e6 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "apk_full", + "short_name": "apk_full", + "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..a1b1d2e --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(apk_full 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 "apk_full") + +# 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..3556b81 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + 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..8c271df --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + app_links + file_selector_windows + firebase_core + share_plus + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +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..21810ec --- /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", "apk_full" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "apk_full" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "apk_full.exe" "\0" + VALUE "ProductName", "apk_full" "\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..07f2cea --- /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"apk_full", 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_