commit 90a49722dd3691e78496cc03fc86c4ee77b51514 Author: ALim Aidrus Date: Mon Aug 4 15:11:24 2025 +0800 Test pre-commit hook diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c028ca5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,45 @@ +# Normalize all text files to LF line endings +* text=auto eol=lf + +# Treat Dart, YAML, XML, Swift, and other source files as text +*.dart text +*.yaml text +*.yml text +*.xml text +*.swift text +*.java text +*.kt text +*.gradle text +*.properties text +*.json text +*.html text +*.css text +*.js text +*.ts text +*.md text +*.txt text + +# Flutter platform-specific files +*.pbxproj text +*.xcconfig text +*.cmake text +*.cc text +*.h text + +# Binary files (no line ending normalization) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.ttf binary +*.otf binary +*.mp3 binary +*.mp4 binary +*.zip binary + +# Lock files and build artifacts +pubspec.lock text +*.lock text +*.log text \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..36e0aa1 --- /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: "b25305a8832cfc6ba632a7f87ad455e319dccce8" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + base_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + - platform: android + create_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + base_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + - platform: ios + create_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + base_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + - platform: linux + create_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + base_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + - platform: macos + create_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + base_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + - platform: web + create_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + base_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + - platform: windows + create_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + base_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac9b912 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# environment_monitoring_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..b9b720c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // Flutter plugin must be applied last + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.environment_monitoring_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + applicationId = "com.example.environment_monitoring_app" + minSdk = 21 + targetSdk = 35 + versionCode = 1 + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + storeFile = file("upload-keystore.jks") + storePassword = "0nly1kn0wthep@5sw0rd" + keyAlias = "upload" + keyPassword = "0nly1kn0wthep@5sw0rd" + } + } + + buildTypes { + getByName("release") { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = false + isShrinkResources = false + } + } +} + +flutter { + source = "../.." +} \ 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..184f8c9 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/environment_monitoring_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/environment_monitoring_app/MainActivity.kt new file mode 100644 index 0000000..1eb2b73 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/environment_monitoring_app/MainActivity.kt @@ -0,0 +1,52 @@ +package com.example.environment_monitoring_app + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +class MainActivity: FlutterActivity() { + // This channel name must match the one used in your Dart code (e.g., in the InSituSamplingService). + private val CHANNEL = "com.example.environment_monitoring_app/usb" + private val ACTION_USB_PERMISSION = "com.example.environment_monitoring_app.USB_PERMISSION" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> + // This block listens for method calls from your Flutter app. + if (call.method == "requestUsbPermission") { + val manager = getSystemService(Context.USB_SERVICE) as UsbManager + var deviceToRequest: UsbDevice? = null + + // Get the Vendor ID and Product ID sent from the Dart code. + val vid = call.argument("vid") + val pid = call.argument("pid") + + // Find the matching USB device connected to the phone. + manager.deviceList.values.forEach { device -> + if (device.vendorId == vid && device.productId == pid) { + deviceToRequest = device + } + } + + deviceToRequest?.let { + // If the device is found, create an intent and request permission from the user. + // The 'usb_serial' package in Flutter will listen for the result of this permission request. + val usbPermissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE) + manager.requestPermission(it, usbPermissionIntent) + result.success(true) // Inform Dart that the request was successfully sent. + } ?: run { + // If the device is not found, send an error back to Dart. + result.error("DEVICE_NOT_FOUND", "Specified USB device not found.", null) + } + } else { + result.notImplemented() + } + } + } +} 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..831300c 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..11bc616 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..ea01225 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..474f8c5 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..d3a1418 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/main/res/xml/device_filter.xml b/android/app/src/main/res/xml/device_filter.xml new file mode 100644 index 0000000..5de1814 --- /dev/null +++ b/android/app/src/main/res/xml/device_filter.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /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.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ab39a10 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/icon2.png b/assets/icon2.png new file mode 100644 index 0000000..3814664 Binary files /dev/null and b/assets/icon2.png differ diff --git a/assets/icon3.png b/assets/icon3.png new file mode 100644 index 0000000..fb76f90 Binary files /dev/null and b/assets/icon3.png differ diff --git a/assets/icon4.png b/assets/icon4.png new file mode 100644 index 0000000..1a984c8 Binary files /dev/null and b/assets/icon4.png differ diff --git a/assets/icon_1_512x512.png b/assets/icon_1_512x512.png new file mode 100644 index 0000000..f1305e0 Binary files /dev/null and b/assets/icon_1_512x512.png differ diff --git a/assets/icon_2_512x512.png b/assets/icon_2_512x512.png new file mode 100644 index 0000000..ca44014 Binary files /dev/null and b/assets/icon_2_512x512.png differ diff --git a/assets/icon_3_512x512.png b/assets/icon_3_512x512.png new file mode 100644 index 0000000..04ae886 Binary files /dev/null and b/assets/icon_3_512x512.png differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b98b448 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.environmentMonitoringApp; + 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.environmentMonitoringApp.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.environmentMonitoringApp.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.environmentMonitoringApp.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 = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = 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 = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.environmentMonitoringApp; + 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.environmentMonitoringApp; + 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..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..179d18c 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..2ef59dd 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..fc235f0 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..8ce555a 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..6f24455 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..212a0cc 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..cdaada8 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..fc235f0 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..3390fe9 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..1b1d3b3 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..230f467 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..e244d08 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..60bb865 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..1698e97 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..1b1d3b3 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..e8c3df0 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..831300c 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..474f8c5 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..b08e4fd 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..d097a85 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..9492165 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..ce00bbd --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Environment Monitoring App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + environment_monitoring_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/auth_provider.dart b/lib/auth_provider.dart new file mode 100644 index 0000000..3978735 --- /dev/null +++ b/lib/auth_provider.dart @@ -0,0 +1,223 @@ +// lib/auth_provider.dart + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'dart:convert'; + +import 'package:environment_monitoring_app/services/api_service.dart'; + +/// A comprehensive provider to manage user authentication, session state, +/// and cached master data for offline use. +class AuthProvider with ChangeNotifier { + final ApiService _apiService = ApiService(); + final DatabaseHelper _dbHelper = DatabaseHelper(); + + // --- Session & Profile State --- + String? _jwtToken; + String? _userEmail; + Map? _profileData; + bool get isLoggedIn => _jwtToken != null; + String? get userEmail => _userEmail; + Map? get profileData => _profileData; + + // --- App State --- + bool _isLoading = true; + bool _isFirstLogin = true; + DateTime? _lastSyncTimestamp; + bool get isLoading => _isLoading; + bool get isFirstLogin => _isFirstLogin; + DateTime? get lastSyncTimestamp => _lastSyncTimestamp; + + // --- Cached Master Data --- + List>? _allUsers; + List>? _tarballStations; + List>? _manualStations; + List>? _tarballClassifications; + List>? _riverManualStations; + List>? _riverTriennialStations; + List>? _departments; + List>? _companies; + List>? _positions; + + // --- Getters for UI access --- + List>? get allUsers => _allUsers; + List>? get tarballStations => _tarballStations; + List>? get manualStations => _manualStations; + List>? get tarballClassifications => _tarballClassifications; + List>? get riverManualStations => _riverManualStations; + List>? get riverTriennialStations => _riverTriennialStations; + List>? get departments => _departments; + List>? get companies => _companies; + List>? get positions => _positions; + + // --- SharedPreferences Keys (made public for BaseApiService) --- + static const String tokenKey = 'jwt_token'; + static const String userEmailKey = 'user_email'; + static const String profileDataKey = 'user_profile_data'; + static const String lastSyncTimestampKey = 'last_sync_timestamp'; + static const String isFirstLoginKey = 'is_first_login'; + + AuthProvider() { + debugPrint('AuthProvider: Initializing...'); + _loadSessionAndSyncData(); + } + + /// Loads the user session from storage and then triggers a data sync. + Future _loadSessionAndSyncData() async { + _isLoading = true; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + _jwtToken = prefs.getString(tokenKey); + _userEmail = prefs.getString(userEmailKey); + _isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true; + final lastSyncMillis = prefs.getInt(lastSyncTimestampKey); + if (lastSyncMillis != null) { + _lastSyncTimestamp = DateTime.fromMillisecondsSinceEpoch(lastSyncMillis); + } + + // Always load from local DB first for instant startup + await _loadDataFromCache(); + + if (_jwtToken != null) { + debugPrint('AuthProvider: Session loaded. Triggering online sync.'); + // Don't await here to allow the UI to build instantly with cached data + syncAllData(); + } else { + debugPrint('AuthProvider: No active session. App is in offline mode.'); + } + + _isLoading = false; + notifyListeners(); + } + + /// The main function to sync all app data. It checks for an internet connection + /// and fetches from the server if available, otherwise it relies on the local cache. + Future syncAllData({bool forceRefresh = false}) async { + final connectivityResult = await Connectivity().checkConnectivity(); + if (connectivityResult != ConnectivityResult.none) { + debugPrint("AuthProvider: Device is ONLINE. Fetching fresh data from server."); + await _fetchDataFromServer(); + } else { + debugPrint("AuthProvider: Device is OFFLINE. Data is already loaded from cache."); + } + notifyListeners(); + } + + /// A dedicated method to refresh only the profile. + Future refreshProfile() async { + final result = await _apiService.refreshProfile(); + if (result['success']) { + // Update the profile data in the provider state + _profileData = result['data']; + // Persist the updated profile data in SharedPreferences + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(profileDataKey, jsonEncode(_profileData)); + notifyListeners(); + } + } + + /// Fetches all master data from the server and caches it locally. + Future _fetchDataFromServer() async { + final result = await _apiService.syncAllData(); + if (result['success']) { + final data = result['data']; + _profileData = data['profile']; + _allUsers = data['allUsers'] != null ? List>.from(data['allUsers']) : null; + _tarballStations = data['tarballStations'] != null ? List>.from(data['tarballStations']) : null; + _manualStations = data['manualStations'] != null ? List>.from(data['manualStations']) : null; + _tarballClassifications = data['tarballClassifications'] != null ? List>.from(data['tarballClassifications']) : null; + _riverManualStations = data['riverManualStations'] != null ? List>.from(data['riverManualStations']) : null; + _riverTriennialStations = data['riverTriennialStations'] != null ? List>.from(data['riverTriennialStations']) : null; + _departments = data['departments'] != null ? List>.from(data['departments']) : null; + _companies = data['companies'] != null ? List>.from(data['companies']) : null; + _positions = data['positions'] != null ? List>.from(data['positions']) : null; + await setLastSyncTimestamp(DateTime.now()); + } + } + + /// Loads all master data from the local cache using DatabaseHelper. + Future _loadDataFromCache() async { + _profileData = await _dbHelper.loadProfile(); + _allUsers = await _dbHelper.loadUsers(); + _tarballStations = await _dbHelper.loadTarballStations(); + _manualStations = await _dbHelper.loadManualStations(); + _tarballClassifications = await _dbHelper.loadTarballClassifications(); + _riverManualStations = await _dbHelper.loadRiverManualStations(); + _riverTriennialStations = await _dbHelper.loadRiverTriennialStations(); + _departments = await _dbHelper.loadDepartments(); + _companies = await _dbHelper.loadCompanies(); + _positions = await _dbHelper.loadPositions(); + debugPrint("AuthProvider: All master data loaded from local DB cache."); + } + + // --- Methods for UI interaction --- + + /// Handles the login process, saving session data and triggering a full data sync. + Future login(String token, Map profile) async { + _jwtToken = token; + _userEmail = profile['email']; + _profileData = profile; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(tokenKey, token); + await prefs.setString(userEmailKey, _userEmail!); + await prefs.setString(profileDataKey, jsonEncode(profile)); + await _dbHelper.saveProfile(profile); + + debugPrint('AuthProvider: Login successful. Session and profile persisted.'); + await syncAllData(forceRefresh: true); + } + + Future setProfileData(Map data) async { + _profileData = data; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(profileDataKey, jsonEncode(data)); + await _dbHelper.saveProfile(data); // Also save to local DB + notifyListeners(); + } + + Future setLastSyncTimestamp(DateTime timestamp) async { + _lastSyncTimestamp = timestamp; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(lastSyncTimestampKey, timestamp.millisecondsSinceEpoch); + notifyListeners(); + } + + Future setIsFirstLogin(bool value) async { + _isFirstLogin = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(isFirstLoginKey, value); + notifyListeners(); + } + + Future logout() async { + debugPrint('AuthProvider: Initiating logout...'); + _jwtToken = null; + _userEmail = null; + _profileData = null; + _lastSyncTimestamp = null; + _isFirstLogin = true; + _allUsers = null; + _tarballStations = null; + _manualStations = null; + _tarballClassifications = null; + _riverManualStations = null; + _riverTriennialStations = null; + _departments = null; + _companies = null; + _positions = null; + + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + await prefs.setBool(isFirstLoginKey, true); + + debugPrint('AuthProvider: All session and cached data cleared.'); + notifyListeners(); + } + + Future> resetPassword(String email) { + return _apiService.post('auth/forgot-password', {'email': email}); + } +} diff --git a/lib/bluetooth/bluetooth_manager.dart b/lib/bluetooth/bluetooth_manager.dart new file mode 100644 index 0000000..d0c3ec8 --- /dev/null +++ b/lib/bluetooth/bluetooth_manager.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; + +//import 'utils/converter.dart'; +import '../serial/utils/converter.dart'; +import 'utils/crc_calculator.dart'; +import 'utils/parameter_helper.dart'; + +enum BluetoothConnectionState { disconnected, connecting, connected } + +class BluetoothManager { + Timer? _dataRequestTimer; + + final FlutterBluetoothSerial _bluetooth = FlutterBluetoothSerial.instance; + BluetoothConnection? _connection; + + // --- State Notifiers --- + final ValueNotifier connectionState = + ValueNotifier(BluetoothConnectionState.disconnected); + // MODIFIED: Changed to ValueNotifier for consistency and reactivity. + final ValueNotifier connectedDeviceName = ValueNotifier(null); + // ADDED: Notifier to hold the parsed Sonde ID. + final ValueNotifier sondeId = ValueNotifier(null); + + final StreamController> _dataStreamController = + StreamController>.broadcast(); + Stream> get dataStream => _dataStreamController.stream; + + // --- Internal State --- + String? connectedDeviceAddress; + int _runningCounter = 0; + int _communicationLevel = 0; + String? _parentAddress; + final List _parameterList = []; + + Future> getPairedDevices() async { + try { + return await _bluetooth.getBondedDevices(); + } catch (e) { + print("Error getting paired devices: $e"); + return []; + } + } + + Future connect(BluetoothDevice device) async { + if (connectionState.value == BluetoothConnectionState.connected) return; + try { + connectionState.value = BluetoothConnectionState.connecting; + _connection = await BluetoothConnection.toAddress(device.address); + + // MODIFIED: Set the .value of the notifier. + connectedDeviceName.value = device.name; + connectedDeviceAddress = device.address; + + connectionState.value = BluetoothConnectionState.connected; + _connection!.input!.listen(_onDataReceived).onDone(disconnect); + } catch (e) { + print("Error connecting: $e"); + disconnect(); + } + } + + void disconnect() { + if (connectionState.value != BluetoothConnectionState.disconnected) { + stopAutoReading(); // Ensure timer is stopped on disconnect + _connection?.dispose(); + _connection = null; + + // MODIFIED: Reset the .value of the notifiers. + connectedDeviceName.value = null; + // ADDED: Reset Sonde ID on disconnect. + sondeId.value = null; + + connectedDeviceAddress = null; + connectionState.value = BluetoothConnectionState.disconnected; + } + } + + /// Starts a periodic timer that requests data automatically. + void startAutoReading({Duration interval = const Duration(seconds: 5)}) { + // Cancel any existing timer to prevent duplicates. + stopAutoReading(); + + // Request the first reading immediately without waiting for the timer. + if (connectionState.value == BluetoothConnectionState.connected) { + startLiveReading(); + } + + // Start a new timer that calls startLiveReading periodically. + _dataRequestTimer = Timer.periodic(interval, (Timer t) { + if (connectionState.value == BluetoothConnectionState.connected) { + startLiveReading(); + } else { + // If we get disconnected for any reason, stop the timer. + stopAutoReading(); + } + }); + } + + /// Stops the automatic data refresh timer. + void stopAutoReading() { + _dataRequestTimer?.cancel(); + _dataRequestTimer = null; + } + + void startLiveReading() { + if (connectionState.value != BluetoothConnectionState.connected) return; + _communicationLevel = 0; + _parameterList.clear(); + _parentAddress = null; + _sendCommand(0); + } + + void _onDataReceived(Uint8List data) { + if (data.isEmpty) return; + String responseHex = Converter.byteArrayToHexString(data).toUpperCase(); + print("Received (Lvl: $_communicationLevel): $responseHex"); + switch (_communicationLevel) { + case 0: + _handleResponseLevel0(responseHex); + break; + case 1: + _handleResponseLevel1(responseHex); + break; + case 2: + _handleResponseLevel2(responseHex); + break; + } + } + + void _handleResponseLevel0(String responseHex) { + try { + if (responseHex.length < 94) { + print("Level 0 response is too short."); + return; + } + + // ADDED: Parse Sonde ID (Serial Number) from the response. + // This uses the same logic as your SerialManager. + String serialHex = responseHex.substring(68, 86); + String serialAscii = Converter.hexToAscii(serialHex); + sondeId.value = serialAscii; + print("Successfully Parsed Sonde ID: ${sondeId.value}"); + + // Your existing logic to parse the Parent Address. + final int dataBlockLength = + int.parse(responseHex.substring(30, 34), radix: 16); + if (dataBlockLength == 38) { + const int dataBlockStart = 34; + const int parentAddressOffset = 52; + final int addressStart = dataBlockStart + parentAddressOffset; + + _parentAddress = responseHex.substring(addressStart, addressStart + 8); + + print("Successfully Parsed Parent Address: $_parentAddress"); + + if (_parentAddress != "00000000") { + _communicationLevel = 1; + + // Give the device a moment before we send the next command. + Future.delayed(const Duration(milliseconds: 500)).then((_) { + _sendCommand(1); // Move to next step + }); + } + } + } catch (e) { + print("Error parsing Level 0 response: $e"); + disconnect(); + } + } + + void _handleResponseLevel1(String responseHex) { + try { + if (responseHex.length < 38) return; + final int dataBlockLength = + int.parse(responseHex.substring(30, 34), radix: 16); + final String parametersDataBlock = + responseHex.substring(34, 34 + (dataBlockLength * 2)); + _parameterList.clear(); + for (int i = 0; i <= parametersDataBlock.length - 6; i += 6) { + String parameterCode = parametersDataBlock.substring(i + 2, i + 6); + _parameterList.add(ParameterHelper.getDescription(parameterCode)); + } + print("Parsed Parameters: $_parameterList"); + _communicationLevel = 2; + + // **** UPDATED CODE **** + // Give the device a moment before requesting the final data. + Future.delayed(const Duration(milliseconds: 500)).then((_) { + _sendCommand(2); // Move to final step + }); + } catch (e) { + print("Error parsing Level 1 response: $e"); + disconnect(); + } + } + + void _handleResponseLevel2(String responseHex) { + try { + if (responseHex.length < 38) return; + final int dataBlockLength = + int.parse(responseHex.substring(30, 34), radix: 16); + final String valuesDataBlock = + responseHex.substring(34, 34 + (dataBlockLength * 2)); + final List parameterValues = []; + for (int i = 0; i <= valuesDataBlock.length - 8; i += 8) { + String valueHex = valuesDataBlock.substring(i, i + 8); + parameterValues.add(Converter.hexToFloat(valueHex)); + } + + if (_parameterList.length == parameterValues.length) { + Map finalReadings = {}; + for (int i = 0; i < _parameterList.length; i++) { + finalReadings[_parameterList[i]] = parameterValues[i]; + } + print("Final Parsed Readings: $finalReadings"); + _dataStreamController.add(finalReadings); + } + // Reset for the next reading sequence + _communicationLevel = 0; + } catch (e) { + print("Error parsing Level 2 response: $e"); + disconnect(); + } + } + + void _sendCommand(int level) { + if (_connection == null || !_connection!.isConnected) { + print("Cannot send command, not connected."); + return; + } + + String commandHex; + if (level == 0) { + commandHex = _getCommand0(); + } else if (level == 1) { + commandHex = _getCommand1(); + } else { + commandHex = _getCommand2(); + } + + Uint8List commandBytes = Converter.hexStringToByteArray(commandHex); + String crcHexString = computeCrc16Ccitt(commandBytes); + String finalHexPacket = commandHex + crcHexString; + Uint8List packetToSend = Converter.hexStringToByteArray(finalHexPacket); + + try { + _connection?.output.add(packetToSend); + _connection?.output.allSent.then((_) { + print("Sent (Lvl: $level): $finalHexPacket"); + }); + } catch (e) { + print("Error sending data: $e"); + disconnect(); + } + } + + String _getCommand0() { + String seqNo = (_runningCounter++ & 255).toRadixString(16).padLeft(2, '0'); + return '7E02${seqNo}0000000002000000200000010000'; + } + + String _getCommand1() { + String seqNo = (_runningCounter++ & 255).toRadixString(16).padLeft(2, '0'); + return '7E02$seqNo${_parentAddress}02000000200000180000'; + } + + String _getCommand2() { + String seqNo = (_runningCounter++ & 255).toRadixString(16).padLeft(2, '0'); + return '7E02$seqNo${_parentAddress}02000000200000190000'; + } + + void dispose() { + disconnect(); + _dataStreamController.close(); + connectionState.dispose(); + // ADDED: Dispose of the new notifiers to prevent memory leaks. + connectedDeviceName.dispose(); + sondeId.dispose(); + } +} \ No newline at end of file diff --git a/lib/bluetooth/utils/converter.dart b/lib/bluetooth/utils/converter.dart new file mode 100644 index 0000000..b953889 --- /dev/null +++ b/lib/bluetooth/utils/converter.dart @@ -0,0 +1,38 @@ +import 'dart:typed_data'; +import 'dart:convert'; + +// ADD THIS FUNCTION +/// Converts a string of hexadecimal characters to its ASCII equivalent. +String hexToAscii(String hex) { + hex = hex.replaceAll(" ", ""); + List bytes = []; + for (int i = 0; i < hex.length; i += 2) { + try { + bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); + } catch (e) { + // Ignore errors from non-hex characters if any + } + } + return ascii.decode(bytes, allowInvalid: true); +} + + +Uint8List hexStringToByteArray(String hex) { + hex = hex.replaceAll(" ", "").toUpperCase(); + if (hex.length % 2 != 0) hex = '0' + hex; + return Uint8List.fromList( + List.generate(hex.length ~/ 2, (i) => int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16)) + ); +} + +String byteArrayToHexString(Uint8List bytes) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(''); +} + +double hexToFloat(String hexString) { + if (hexString.length != 8) throw ArgumentError("Hex string must be 8 characters for a 32-bit float."); + final int intValue = int.parse(hexString, radix: 16); + final ByteData byteData = ByteData(4); + byteData.setUint32(0, intValue, Endian.big); + return byteData.getFloat32(0, Endian.big); +} \ No newline at end of file diff --git a/lib/bluetooth/utils/crc_calculator.dart b/lib/bluetooth/utils/crc_calculator.dart new file mode 100644 index 0000000..20d415e --- /dev/null +++ b/lib/bluetooth/utils/crc_calculator.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; + +String computeCrc16Ccitt(Uint8List bytes) { + int crc = 0xFFFF; + int polynomial = 0x1021; + + for (int b in bytes) { + for (int i = 0; i < 8; i++) { + bool bit = ((b >> (7 - i) & 1) == 1); + bool c15 = ((crc >> 15 & 1) == 1); + crc <<= 1; + if (c15 ^ bit) { + crc ^= polynomial; + } + } + } + crc &= 0xffff; + return crc.toRadixString(16).padLeft(4, '0').toUpperCase(); +} \ No newline at end of file diff --git a/lib/bluetooth/utils/parameter_helper.dart b/lib/bluetooth/utils/parameter_helper.dart new file mode 100644 index 0000000..d906aaf --- /dev/null +++ b/lib/bluetooth/utils/parameter_helper.dart @@ -0,0 +1,185 @@ +/// A utility class that translates YSI instrument hex codes into human-readable descriptions. +/// This replaces the `ParameterHelper.java` class using a more efficient Map data structure. +class ParameterHelper { + /// A private, static, and constant map for maximum performance. + /// The map is created only once when the app starts. + static const Map _parameterMap = { + '000100010001': 'Combo Probe for PH and ORP, original HW', + '000100010002': 'PH only, Original HW', + '000100010003': 'ammonium', + '000100010004': 'Potasium', + '000100010005': 'Nitrate', + '000100010006': 'Chloride', + '000100010007': 'Combo Probe for PH and ORP, hybrid HW for PH drift', + '000100010008': 'PH only, hybrid HW for PH drift', + '000100010009': 'Combo Probe for PH and ORP, VREF offset HW for PH drift', + '00010001000A': 'PH only, VREF offset HW for PH drift', + '00010001000B': 'Combo Probe for PH and ORP, Floating Reference is at 1.5V', + '00010001000C': 'PH only, Floating Reference is at 1.5V', + '000100020001': 'Probe for ODO', + '000100030001': 'Combo Probe for Conductivity and Temperature', + '000100030002': 'Combo Probe for Wet Cal Conductivity and Temperature', + '000100030003': 'Combo Probe for High accuracy Conductivity and Temperature', + '000100030004': 'Combo Probe for Conductivity and Temperature, Wiped', + '00010003FFFF': 'Mfg tester for Conductivity for CT probe', + '000100040001': 'Probe for Turbidity', + '000100050001': 'EXO1 - 4 port Sonde', + '000100050002': 'EXO2 - 6 port Sonde', + '000100050003': 'EXO3 - 8 port Sonde', + '000100060001': 'Combo of Chlorophyll and BGA-PC', + '000100060002': 'Combo of Chlorophyll and BGA-PE', + '000100070001': 'depth probe, Non-Vented, Standard 0-250m', + '000100070002': 'depth probe, Non-Vented, Medium 0-100m', + '000100070003': 'depth probe, Non-Vented, Shallow 0-10m', + '000100070004': 'depth probe, Vented, 0-10m', + '000100080001': 'Wiper probe', + '000100090001': 'Signal Output Adapter - DCP', + '0001000A0001': 'Original Handheld (used primarily during manufacturing Testing)', + '0001000A0002': 'New Handheld (ProDSS Style) with GPS', + '0001000A0003': 'New Handheld (ProDSS Style) w/out GPS', + '0001000B0001': 'fDOM', + '0001000C0001': 'metal', + '0001000E0001': 'EXO Test Board (reserved for R&D)', + '0001000E0002': 'High Accuracy CT Test board', + '0001000E0003': 'Phloptode', + '0001000F0001': 'Deep UV A Channel T Channel wastewater sensor', + '0001000F0002': 'Deep UV NOx Nitrate+Nitrite sensor', + '000100100001': 'Signal Output Adapter - DCP Gen 2', + '000100100002': 'Signal Output Adapter - DCP Gen 2 - Modbus', + '000100110001': 'Used on both EXO and DSS platforms.', + '000200010001': 'Variable Flourometer Sensor', + '000300010001': 'Combo ODO/Conductivity/Temperature', + '0000': 'Common Message', + '0001': 'Sonde', + '0002': 'PH', + '0003': 'PH: PH units', + '0004': 'PH: mV', + '0005': 'ORP', + '0006': 'ORP: mV', + '0007': 'ORP: mV UnAdjusted', + '0008': 'Optical Dissolved Oxygen', + '0009': 'Optical Dissolved Oxygen: Compensated % Saturation', + '000A': 'Optical Dissolved Oxygen: Compensated mg/L', + '000B': 'Internal uP Temp', + '000C': 'Internal uP Temp: Degrees Celcius', + '000D': 'External Temp', + '000E': 'External Temp: Degrees Celcius', + '000F': 'Barometer', + '0010': 'Barometer: mmHg', + '0011': 'Conductivity', + '0012': 'Conductivity: us/cm', + '0013': 'Conductivity: Salinity', + '0014': 'Turbidity', + '0015': 'Turbidity: FNU', + '0016': 'Turbidity: TSS', + '0017': 'Turbidity: RAW', + '0018': 'Conductivity: Specific Conductivity', + '0019': 'Chlorophyll', + '001A': 'Chlorophyll: ug/L', + '001B': 'BGA-PE', + '001C': 'BGA-PE: cells/mL - replaced with ug/L (0x003B)', + '001D': 'BGA-PC', + '001E': 'BGA-PC: cells/mL - replaced with ug/L (0x003A)', + '001F': 'Chlorophyll: RFU', + '0020': 'BGA-PE: RFU', + '0021': 'BGA-PC: RFU', + '0022': 'Pressure:', + '0023': 'Pressure: psi a', + '0024': 'Pressure: depth', + '0025': 'Etime', + '0026': 'Wiper:', + '0027': 'Wiper: Position Volts', + '0028': 'Sonde: Date and Time', + '0029': 'Sonde: Battery Voltage', + '002A': 'Sonde: External Voltage', + '002B': 'Sonde: Internal Pressure Voltage', + '002C': 'fDOM:', + '002D': 'fDOM: RAW', + '002E': 'fDOM: RFU', + '002F': 'Chlorophyll: RAW', + '0030': 'BGA-PE: RAW', + '0031': 'BGA-PC: RAW', + '0032': 'Wiper: Current(mA)', + '0033': 'Wiper: Profile Time(s)', + '0034': 'Pressure: RAW', + '0035': 'ODO: RAW %Sat', + '0036': 'ODO: RAW mg/L', + '0037': 'fDOM: QSU', + '0038': 'Wiper: Peak Current (mA)', + '0039': 'Wiper: Parking Retries', + '003A': 'BGA-PC: ug/L', + '003B': 'BGA-PE: ug/L', + '003C': 'GPS Latitude', + '003D': 'GPS Longitude', + '003E': 'Conductivity: RAW us/cm', + '003F': 'Pressure: psi g', + '0040': 'Reference Temp', + '0041': 'ODO: % EU', + '0042': 'Ammonium', + '0043': 'Ammonium (NH4+) mV', + '0044': 'Ammonium (NH4+) mg/L', + '0048': 'Nitrate', + '0049': 'Nitrate (NO-3) mV', + '004A': 'Nitrate (NO-3) mg/L', + '004B': 'Chloride', + '004C': 'Chloride (Cl-) mV', + '004D': 'Chloride (Cl-) mg/L', + '004E': 'Ammonia (NH3)', + '004F': 'ODO: % Local', + '0050': 'Conductivity: nLF us/cm', + '0051': 'Sonde: Sensor Power mW', + '0052': 'Sonde: RTC Battery Voltage', + '0053': 'Variable Flourescent (VF) meter', + '0054': 'VF: Units TBD', + '0055': 'Conductivity:TDS mg/L', + '0056': 'Pressure: vert pos', + '0057': 'GPS Date', + '0058': 'GPS Time', + '0059': 'GPS Altitude', + '005A': 'GPS Speed Over Ground (SOG)', + '005B': 'GPS Course Over Ground (COG)', + '005C': 'Barometer Temperature', + '005D': 'Conductivity Resistance (ohms)', + '005E': 'ODO ppm', + '005F': 'Turbidity NTU', + '0060': 'Temprorary Calibration Value', + '0061': 'Sigma', + '0062': 'Sigma-T', + '0063': 'Deep UV Wastewater: Channel A', + '0064': 'Deep UV Wastewater: Channel A Raw', + '0065': 'Deep UV Wastewater: Channel A RFU', + '0066': 'Deep UV Wastewater: Channel A Concentration', + '0067': 'Deep UV Wastewater: Channel T', + '0068': 'Deep UV Wastewater: Channel T Raw', + '0069': 'Deep UV Wastewater: Channel T RFU', + '006A': 'Deep UV Wastewater: Channel T Concentration', + '006B': 'Deep UV Wastewater: Ratio', + '006C': 'Deep UV NOx Ch238', + '006D': 'Deep UV NOx Ch238 Raw', + '006E': 'Deep UV NOx Ch238Ref', + '006F': 'Deep UV NOx Ch238Ref Raw', + '0070': 'Deep UV NOx Ch238 Absorbance', + '0071': 'Deep UV NOx Ch238 Absorbance Turb Corrected', + '0072': 'Deep UV NOx Ch275', + '0073': 'Deep UV NOx Ch275 Raw', + '0074': 'Deep UV NOx Ch275Ref', + '0075': 'Deep UV NOx Ch275Ref Raw', + '0076': 'Deep UV NOx Ch275 Absorbance', + '0077': 'Deep UV NOx Ch275 Absorbance Turb Corrected', + '0078': 'Deep UV NOx', + '0079': 'Deep UV NOx RAW', + '007A': 'Deep UV NOx mg/L', + '007B': 'fDOM ppb', + '9900': 'SOA/DCP', + 'FF00': 'Handheld', + 'FF01': 'Hipster', + 'FF11': 'Conductivity Manufacturing Tester', + }; + + /// Takes a hex code string and returns its human-readable description. + /// If the code is not found, it returns 'UNKNOWN'. + static String getDescription(String hexCode) { + // The '??' operator provides a default value if the key is not in the map. + return _parameterMap[hexCode.toUpperCase()] ?? 'UNKNOWN'; + } +} \ No newline at end of file diff --git a/lib/bluetooth/widgets/bluetooth_device_list_dialog.dart b/lib/bluetooth/widgets/bluetooth_device_list_dialog.dart new file mode 100644 index 0000000..af0ab33 --- /dev/null +++ b/lib/bluetooth/widgets/bluetooth_device_list_dialog.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; + +/// A dialog that displays a list of paired Bluetooth devices for the user to select. +Future showBluetoothDeviceListDialog({ + required BuildContext context, + required List devices, +}) { + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Select a Paired Device'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + itemCount: devices.length, + shrinkWrap: true, + itemBuilder: (BuildContext context, int index) { + final device = devices[index]; + return ListTile( + title: Text(device.name ?? 'Unknown Device'), + subtitle: Text(device.address), + onTap: () { + // When a device is tapped, pop the dialog and return the selected device. + Navigator.of(context).pop(device); + }, + ); + }, + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + // Pop the dialog and return null if canceled. + Navigator.of(context).pop(null); + }, + ), + ], + ); + }, + ); +} diff --git a/lib/collapsible_sidebar.dart b/lib/collapsible_sidebar.dart new file mode 100644 index 0000000..1296def --- /dev/null +++ b/lib/collapsible_sidebar.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; + +// --- Data Structure for Sidebar Menu Items --- +class SidebarItem { + final IconData? icon; // Now nullable, as we might use an image + final String label; + final String? route; // Null if it's a parent category + final List? children; // Sub-items + final bool isParent; + final String? imagePath; // New: Optional path to an image asset + + SidebarItem({ + this.icon, + required this.label, + this.route, + this.children, + this.isParent = false, + this.imagePath, // Initialize the new property + }) : assert(icon != null || imagePath != null, 'Either icon or imagePath must be provided'); // Ensure one is present +} + +// --- Collapsible Sidebar Widget --- +class CollapsibleSidebar extends StatefulWidget { + final Function(String route) onNavigate; + final bool isCollapsed; // Receive collapse state from HomePage + final VoidCallback onToggle; // Receive toggle callback from HomePage (for AppBar button) + + const CollapsibleSidebar({ + super.key, + required this.onNavigate, + required this.isCollapsed, + required this.onToggle, + }); + + @override + State createState() => _CollapsibleSidebarState(); +} + +class _CollapsibleSidebarState extends State { + // Define your menu items here based on the routes you provided + late final List _menuItems; + + @override + void initState() { + super.initState(); + _menuItems = [ + SidebarItem( + // Reverted to IconData for the top-level 'Air' category + icon: Icons.cloud, + label: "Air", + isParent: true, + children: [ + // Added Manual sub-category for Air + SidebarItem( + icon: Icons.handshake, // Example icon for Manual + label: "Manual", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'), + SidebarItem(icon: Icons.edit_note, label: "Manual Sampling", route: '/air/manual/manual-sampling'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'), + SidebarItem(icon: Icons.article, label: "Data Log", route: '/air/manual/data-log'), + SidebarItem(icon: Icons.image, label: "Image Request", route: '/air/manual/image-request'), + ], + ), + SidebarItem( + icon: Icons.trending_up, label: "Continuous", isParent: true, children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/continuous/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/air/continuous/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/air/continuous/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/continuous/report'), + ]), + SidebarItem( + icon: Icons.search, label: "Investigative", isParent: true, children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/investigative/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/air/investigative/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/air/investigative/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/investigative/report'), + ]), + ], + ), + SidebarItem( + // Reverted to IconData for the top-level 'River' category + icon: Icons.water, + label: "River", + isParent: true, + children: [ + // Added Manual sub-category for River + SidebarItem( + icon: Icons.handshake, // Example icon for Manual + label: "Manual", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/manual/dashboard'), + SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/manual/report'), + SidebarItem(icon: Icons.date_range, label: "Triennial Sampling", route: '/river/manual/triennial'), + SidebarItem(icon: Icons.article, label: "Data Log", route: '/river/manual/data-log'), + SidebarItem(icon: Icons.image, label: "Image Request", route: '/river/manual/image-request'), + ], + ), + SidebarItem( + icon: Icons.trending_up, label: "Continuous", isParent: true, children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/continuous/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/river/continuous/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/river/continuous/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/continuous/report'), + ]), + SidebarItem( + icon: Icons.search, label: "Investigative", isParent: true, children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/investigative/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/river/investigative/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'), + ]), + ], + ), + SidebarItem( + // Reverted to IconData for the top-level 'Marine' category + icon: Icons.sailing, + label: "Marine", + isParent: true, + children: [ + // Added Manual sub-category for Marine + SidebarItem( + icon: Icons.handshake, // Example icon for Manual + label: "Manual", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/manual/dashboard'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/manual/info'), + SidebarItem(icon: Icons.assignment, label: "Pre-Sampling", route: '/marine/manual/pre-sampling'), + SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/marine/manual/in-situ'), + SidebarItem(icon: Icons.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'), + SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'), + SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'), + ], + ), + SidebarItem( + icon: Icons.trending_up, label: "Continuous", isParent: true, children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/continuous/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/continuous/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/continuous/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/continuous/report'), + ]), + SidebarItem( + icon: Icons.search, label: "Investigative", isParent: true, children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/investigative/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/investigative/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'), + ]), + ], + ), + // Added Settings menu item + SidebarItem(icon: Icons.settings, label: "Settings", route: '/settings'), + ]; + } + + @override + Widget build(BuildContext context) { + return Container( + width: widget.isCollapsed ? 70 : 280, // Increased expanded width for sub-menus + color: Theme.of(context).primaryColor, // Use theme primary color for sidebar + child: Column( + children: [ + const SizedBox(height: 16), // Spacing below the (now removed) toggle button area + + Expanded( + child: ListView( + padding: EdgeInsets.zero, // Remove default listview padding + children: [ + if (!widget.isCollapsed) ...[ + // If sidebar is expanded, show full categories with sub-menus + for (var item in _menuItems) + _buildExpandableNavItem(item), + ] else ...[ + // If sidebar is collapsed, only show icons for top-level items + for (var item in _menuItems) + _buildCollapsedNavItem(item), + ], + ], + ), + ), + const Divider(color: Colors.white24, height: 1), // Separator before logout + // Logout item, using an icon as it's a standard action + _buildNavItem(Icons.logout, "Logout", '/logout', isTopLevel: true), + ], + ), + ); + } + + // Helper to build the leading widget (Icon or Image) for a sidebar item + Widget _buildLeadingWidget(SidebarItem item) { + // Now only checks for icon, as imagePath is not used for top-level items anymore + // but the property still exists on SidebarItem for potential future use or other items. + return Icon(item.icon, color: Colors.white); + } + + // Builds an expandable item for parent categories (only when sidebar is expanded) + Widget _buildExpandableNavItem(SidebarItem item) { + if (item.children == null || item.children!.isEmpty) { + // This case handles a top-level item that is NOT a parent, + // but if you have such an item, it should probably go through _buildNavItem directly. + // For this structure, all top-level items are parents. + return _buildNavItem(item.icon, item.label, item.route ?? '', isTopLevel: true, imagePath: item.imagePath); + } + + return Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), // Hide divider in expansion tile + child: ExpansionTile( + initiallyExpanded: false, // You can set this to true if you want some categories open by default + leading: _buildLeadingWidget(item), // Use the helper for leading widget + title: Text(item.label, style: const TextStyle(color: Colors.white)), + iconColor: Colors.white, + collapsedIconColor: Colors.white, + childrenPadding: const EdgeInsets.only(left: 20.0), // Indent sub-items + children: item.children!.map((childItem) { + if (childItem.isParent) { + // Nested expansion tiles for sub-categories like "Manual", "Continuous" + return _buildExpandableNavItem(childItem); + } else { + // Leaf item (actual navigation link) + return _buildNavItem(childItem.icon, childItem.label, childItem.route ?? '', imagePath: childItem.imagePath); + } + }).toList(), + ), + ); + } + + // Builds a regular navigation item (for sub-items when expanded, or top-level when collapsed) + Widget _buildNavItem(IconData? icon, String label, String route, {bool isTopLevel = false, String? imagePath}) { + return InkWell( + onTap: () { + if (route.isNotEmpty) { + widget.onNavigate(route); + } + }, + child: Padding( + padding: EdgeInsets.symmetric( + vertical: 12, + horizontal: isTopLevel && widget.isCollapsed ? 0 : 8, // Center icon if top-level and collapsed + ), + child: isTopLevel && widget.isCollapsed + ? Center( + child: icon != null + ? Icon(icon, color: Colors.white) + : const SizedBox.shrink(), // Fallback if no icon or imagePath + ) // Only icon when collapsed + : Row( + children: [ + icon != null + ? Icon(icon, color: Colors.white) + : const SizedBox.shrink(), // Fallback if no icon or imagePath + const SizedBox(width: 12), + Expanded( // Use Expanded to prevent text overflow + child: Text(label, style: const TextStyle(color: Colors.white)), + ), + ], + ), + ), + ); + } + + // Builds a collapsed navigation item (only icon/image) for top-level categories + Widget _buildCollapsedNavItem(SidebarItem item) { + return InkWell( + onTap: () { + if (item.route != null && item.route!.isNotEmpty) { + widget.onNavigate(item.route!); + } else { + // If a parent item is tapped when collapsed, expand the sidebar + widget.onToggle(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 0), // Adjusted vertical padding + child: Column( + mainAxisSize: MainAxisSize.min, // Use minimum space + children: [ + item.icon != null + ? Icon(item.icon, color: Colors.white, size: 24) // Icon size + : const SizedBox.shrink(), // Fallback if no icon + const SizedBox(height: 4), // Small space between icon and text + Text( + item.label, + style: const TextStyle( + color: Colors.white, + fontSize: 10, // Smaller font size to fit + ), + overflow: TextOverflow.ellipsis, // Handle long labels + maxLines: 1, // Ensure it stays on one line + textAlign: TextAlign.center, // Center the text + ), + ], + ), + ), + ); + } +} diff --git a/lib/home_page.dart b/lib/home_page.dart new file mode 100644 index 0000000..fc4bb75 --- /dev/null +++ b/lib/home_page.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/collapsible_sidebar.dart'; // Import your sidebar widget + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + bool _isSidebarCollapsed = true; + String _currentSelectedRoute = '/home'; + + @override + Widget build(BuildContext context) { + final auth = Provider.of(context); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: Icon(_isSidebarCollapsed ? Icons.menu : Icons.close, color: Colors.white), + onPressed: () { + setState(() { + _isSidebarCollapsed = !_isSidebarCollapsed; + }); + }, + ), + title: const Text("Environment Monitoring"), + actions: [ + IconButton( + icon: const Icon(Icons.person), + onPressed: () { + Navigator.pushNamed(context, '/profile'); + }, + ), + ], + ), + body: Row( + children: [ + CollapsibleSidebar( + isCollapsed: _isSidebarCollapsed, + onToggle: () { + setState(() { + _isSidebarCollapsed = !_isSidebarCollapsed; + }); + }, + onNavigate: (route) { + setState(() { + _currentSelectedRoute = route; + }); + Navigator.pushNamed(context, route); + }, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Welcome, ${auth.userEmail ?? 'User'}", + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 32), + Text( + "Select a Department:", + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + // Updated navigation to the new department home pages + _buildNavButton(context, "Air", Icons.cloud, '/air/home'), + _buildNavButton(context, "River", Icons.water, '/river/home'), + _buildNavButton(context, "Marine", Icons.sailing, '/marine/home'), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon, size: 24), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + textStyle: const TextStyle(fontSize: 16), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..208e164 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,269 @@ +// lib/main.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +// CHANGED: Added imports for MultiProvider and the services to be provided. +import 'package:provider/single_child_widget.dart'; +import 'package:environment_monitoring_app/services/local_storage_service.dart'; +import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; +import 'package:environment_monitoring_app/services/telegram_service.dart'; + +import 'package:environment_monitoring_app/theme.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; + +// Core Screens +import 'package:environment_monitoring_app/screens/login.dart'; +import 'package:environment_monitoring_app/screens/register.dart'; +import 'package:environment_monitoring_app/screens/forgot_password.dart'; +import 'package:environment_monitoring_app/screens/logout.dart'; +import 'package:environment_monitoring_app/home_page.dart'; +import 'package:environment_monitoring_app/screens/profile.dart'; +import 'package:environment_monitoring_app/screens/settings.dart'; + +// Department Home Pages +import 'package:environment_monitoring_app/screens/air/air_home_page.dart'; +import 'package:environment_monitoring_app/screens/river/river_home_page.dart'; +import 'package:environment_monitoring_app/screens/marine/marine_home_page.dart'; + +// Air Screens +import 'package:environment_monitoring_app/screens/air/manual/air_manual_dashboard.dart'; +import 'package:environment_monitoring_app/screens/air/manual/manual_sampling.dart' as airManualSampling; +import 'package:environment_monitoring_app/screens/air/manual/report.dart' as airManualReport; +import 'package:environment_monitoring_app/screens/air/manual/data_status_log.dart' as airManualDataStatusLog; +import 'package:environment_monitoring_app/screens/air/manual/image_request.dart' as airManualImageRequest; +import 'package:environment_monitoring_app/screens/air/continuous/air_continuous_dashboard.dart'; +import 'package:environment_monitoring_app/screens/air/continuous/overview.dart' as airContinuousOverview; +import 'package:environment_monitoring_app/screens/air/continuous/entry.dart' as airContinuousEntry; +import 'package:environment_monitoring_app/screens/air/continuous/report.dart' as airContinuousReport; +import 'package:environment_monitoring_app/screens/air/investigative/air_investigative_dashboard.dart'; +import 'package:environment_monitoring_app/screens/air/investigative/overview.dart' as airInvestigativeOverview; +import 'package:environment_monitoring_app/screens/air/investigative/entry.dart' as airInvestigativeEntry; +import 'package:environment_monitoring_app/screens/air/investigative/report.dart' as airInvestigativeReport; + +// River Screens +import 'package:environment_monitoring_app/screens/river/manual/river_manual_dashboard.dart'; +// NOTE: This import points to the main stepper screen for the River In-Situ workflow. +import 'package:environment_monitoring_app/screens/river/manual/in_situ_sampling.dart' as riverManualInSituSampling; +import 'package:environment_monitoring_app/screens/river/manual/data_status_log.dart' as riverManualDataStatusLog; + +//import 'package:environment_monitoring_app/screens/river/manual/in_situ_sampling.dart' as riverManualInSituSampling; +import 'package:environment_monitoring_app/screens/river/manual/report.dart' as riverManualReport; +import 'package:environment_monitoring_app/screens/river/manual/triennial_sampling.dart' as riverManualTriennialSampling; +//import 'package:environment_monitoring_app/screens/river/manual/data_status_log.dart' as riverManualDataStatusLog; +import 'package:environment_monitoring_app/screens/river/manual/image_request.dart' as riverManualImageRequest; +import 'package:environment_monitoring_app/screens/river/continuous/river_continuous_dashboard.dart'; +import 'package:environment_monitoring_app/screens/river/continuous/overview.dart' as riverContinuousOverview; +import 'package:environment_monitoring_app/screens/river/continuous/entry.dart' as riverContinuousEntry; +import 'package:environment_monitoring_app/screens/river/continuous/report.dart' as riverContinuousReport; +import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_dashboard.dart'; +import 'package:environment_monitoring_app/screens/river/investigative/overview.dart' as riverInvestigativeOverview; +import 'package:environment_monitoring_app/screens/river/investigative/entry.dart' as riverInvestigativeEntry; +import 'package:environment_monitoring_app/screens/river/investigative/report.dart' as riverInvestigativeReport; + +// Marine Screens +import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_dashboard.dart'; +import 'package:environment_monitoring_app/screens/marine/manual/info_centre_document.dart' as marineManualInfoCentreDocument; +import 'package:environment_monitoring_app/screens/marine/manual/pre_sampling.dart' as marineManualPreSampling; +import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling; +import 'package:environment_monitoring_app/screens/marine/manual/report.dart' as marineManualReport; +import 'package:environment_monitoring_app/screens/marine/manual/data_status_log.dart' as marineManualDataStatusLog; +import 'package:environment_monitoring_app/screens/marine/manual/image_request.dart' as marineManualImageRequest; +import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_dashboard.dart'; +import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview; +import 'package:environment_monitoring_app/screens/marine/continuous/entry.dart' as marineContinuousEntry; +import 'package:environment_monitoring_app/screens/marine/continuous/report.dart' as marineContinuousReport; +import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_dashboard.dart'; +import 'package:environment_monitoring_app/screens/marine/investigative/overview.dart' as marineInvestigativeOverview; +import 'package:environment_monitoring_app/screens/marine/investigative/entry.dart' as marineInvestigativeEntry; +import 'package:environment_monitoring_app/screens/marine/investigative/report.dart' as marineInvestigativeReport; + +import 'package:environment_monitoring_app/models/tarball_data.dart'; +import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step1.dart'; +import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step2.dart'; +import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step3_summary.dart'; + + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + setupServices(); + + runApp( + // CHANGED: Converted to MultiProvider to support all necessary services. + MultiProvider( + providers: [ + // The original AuthProvider + ChangeNotifierProvider(create: (_) => AuthProvider()), + // Provider for Local Storage Service + Provider(create: (_) => LocalStorageService()), + // Provider for the River In-Situ Sampling Service + Provider(create: (_) => RiverInSituSamplingService()), + // NOTE: You would also add your Marine InSituSamplingService here if needed by Provider. + ], + child: const RootApp(), + ), + ); +} + +void setupServices() { + final telegramService = TelegramService(); + + Future.delayed(const Duration(seconds: 5), () { + debugPrint("[Main] Performing initial alert queue processing on app start."); + telegramService.processAlertQueue(); + }); + + Connectivity().onConnectivityChanged.listen((List results) { + if (results.contains(ConnectivityResult.mobile) || results.contains(ConnectivityResult.wifi)) { + debugPrint("[Main] Internet connection detected. Triggering alert queue processing."); + telegramService.processAlertQueue(); + } else { + debugPrint("[Main] Internet connection lost."); + } + }); +} + +class RootApp extends StatelessWidget { + const RootApp({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, auth, child) { + Widget homeWidget; + if (auth.isLoading) { + homeWidget = const SplashScreen(); + } else if (auth.isLoggedIn) { + homeWidget = const HomePage(); + } else { + homeWidget = const LoginScreen(); + } + + return MaterialApp( + title: 'Environment Monitoring App', + theme: AppTheme.darkBlueTheme, + debugShowCheckedModeBanner: false, + home: homeWidget, + + onGenerateRoute: (settings) { + if (settings.name == '/marine/manual/tarball/step2') { + final args = settings.arguments as TarballSamplingData; + return MaterialPageRoute(builder: (context) { + return TarballSamplingStep2(data: args); + }); + } + if (settings.name == '/marine/manual/tarball/step3') { + final args = settings.arguments as TarballSamplingData; + return MaterialPageRoute(builder: (context) { + return TarballSamplingStep3Summary(data: args); + }); + } + // NOTE: The River In-Situ form uses an internal stepper, + // so it does not require onGenerateRoute logic for its steps. + return null; + }, + routes: { + // Auth Routes + '/register': (context) => const RegisterScreen(), + '/forgot-password': (context) => ForgotPasswordScreen(), + '/logout': (context) => const LogoutScreen(), + '/home': (context) => const HomePage(), + '/profile': (context) => const ProfileScreen(), + '/settings': (context) => const SettingsScreen(), + + // Department Home Pages + '/air/home': (context) => const AirHomePage(), + '/river/home': (context) => const RiverHomePage(), + '/marine/home': (context) => const MarineHomePage(), + + // Air Manual + '/air/manual/dashboard': (context) => AirManualDashboard(), + '/air/manual/manual-sampling': (context) => airManualSampling.AirManualSampling(), + '/air/manual/report': (context) => airManualReport.AirManualReport(), + '/air/manual/data-log': (context) => airManualDataStatusLog.AirManualDataStatusLog(), + '/air/manual/image-request': (context) => airManualImageRequest.AirManualImageRequest(), + + // Air Continuous + '/air/continuous/dashboard': (context) => AirContinuousDashboard(), + '/air/continuous/overview': (context) => airContinuousOverview.OverviewScreen(), + '/air/continuous/entry': (context) => airContinuousEntry.EntryScreen(), + '/air/continuous/report': (context) => airContinuousReport.ReportScreen(), + + // Air Investigative + '/air/investigative/dashboard': (context) => AirInvestigativeDashboard(), + '/air/investigative/overview': (context) => airInvestigativeOverview.OverviewScreen(), + '/air/investigative/entry': (context) => airInvestigativeEntry.EntryScreen(), + '/air/investigative/report': (context) => airInvestigativeReport.ReportScreen(), + + // River Manual + '/river/manual/dashboard': (context) => RiverManualDashboard(), + '/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(), + + //'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSampling(), + '/river/manual/report': (context) => riverManualReport.RiverManualReport(), + '/river/manual/triennial': (context) => riverManualTriennialSampling.RiverTriennialSampling(), + '/river/manual/data-log': (context) => riverManualDataStatusLog.RiverDataStatusLog(), + //'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(), + '/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(), + + // River Continuous + '/river/continuous/dashboard': (context) => RiverContinuousDashboard(), + '/river/continuous/overview': (context) => riverContinuousOverview.OverviewScreen(), + '/river/continuous/entry': (context) => riverContinuousEntry.EntryScreen(), + '/river/continuous/report': (context) => riverContinuousReport.ReportScreen(), + + // River Investigative + '/river/investigative/dashboard': (context) => RiverInvestigativeDashboard(), + '/river/investigative/overview': (context) => riverInvestigativeOverview.OverviewScreen(), + '/river/investigative/entry': (context) => riverInvestigativeEntry.EntryScreen(), + '/river/investigative/report': (context) => riverInvestigativeReport.ReportScreen(), + + // Marine Manual + '/marine/manual/dashboard': (context) => MarineManualDashboard(), + '/marine/manual/info': (context) => marineManualInfoCentreDocument.MarineInfoCentreDocument(), + '/marine/manual/pre-sampling': (context) => marineManualPreSampling.MarinePreSampling(), + '/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(), + '/marine/manual/tarball': (context) => const TarballSamplingStep1(), + '/marine/manual/report': (context) => marineManualReport.MarineManualReport(), + '/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), + '/marine/manual/image-request': (context) => marineManualImageRequest.MarineManualImageRequest(), + + // Marine Continuous + '/marine/continuous/dashboard': (context) => MarineContinuousDashboard(), + '/marine/continuous/overview': (context) => marineContinuousOverview.OverviewScreen(), + '/marine/continuous/entry': (context) => marineContinuousEntry.EntryScreen(), + '/marine/continuous/report': (context) => marineContinuousReport.ReportScreen(), + + // Marine Investigative + '/marine/investigative/dashboard': (context) => MarineInvestigativeDashboard(), + '/marine/investigative/overview': (context) => marineInvestigativeOverview.OverviewScreen(), + '/marine/investigative/entry': (context) => marineInvestigativeEntry.EntryScreen(), + '/marine/investigative/report': (context) => marineInvestigativeReport.ReportScreen(), + }, + ); + }, + ); + } +} + +class SplashScreen extends StatelessWidget { + const SplashScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 20), + Text('Loading app data...', style: TextStyle(fontSize: 16)), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/models/in_situ_sampling_data.dart b/lib/models/in_situ_sampling_data.dart new file mode 100644 index 0000000..9c38ff3 --- /dev/null +++ b/lib/models/in_situ_sampling_data.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +/// A data model class to hold all information for the multi-step +/// In-Situ Sampling form. +class InSituSamplingData { + // --- Step 1: Sampling & Station Info --- + String? firstSamplerName; + int? firstSamplerUserId; + Map? secondSampler; + String? samplingDate; + String? samplingTime; + String? samplingType; + String? sampleIdCode; + + String? selectedStateName; + String? selectedCategoryName; + Map? selectedStation; + + String? stationLatitude; + String? stationLongitude; + String? currentLatitude; + String? currentLongitude; + double? distanceDifferenceInKm; + String? distanceDifferenceRemarks; + + // --- Step 2: Site Info & Photos --- + String? weather; + String? tideLevel; + String? seaCondition; + String? eventRemarks; + String? labRemarks; + + File? leftLandViewImage; + File? rightLandViewImage; + File? waterFillingImage; + File? seawaterColorImage; + File? phPaperImage; + + File? optionalImage1; + String? optionalRemark1; + File? optionalImage2; + String? optionalRemark2; + File? optionalImage3; + String? optionalRemark3; + File? optionalImage4; + String? optionalRemark4; + + // --- Step 3: Data Capture --- + String? sondeId; + String? dataCaptureDate; + String? dataCaptureTime; + double? oxygenConcentration; + double? oxygenSaturation; + double? ph; + double? salinity; + double? electricalConductivity; + double? temperature; + double? tds; + double? turbidity; + double? tss; + double? batteryVoltage; + + // --- Post-Submission Status --- + String? submissionStatus; + String? submissionMessage; + String? reportId; + + // REPAIRED: Added a constructor to accept initial values. + InSituSamplingData({ + this.samplingDate, + this.samplingTime, + }); + + /// Converts the data model into a Map for the API form data. + Map toApiFormData() { + final Map map = {}; + + void add(String key, dynamic value) { + if (value != null) { + map[key] = value.toString(); + } + } + + // Step 1 Data + add('first_sampler_user_id', firstSamplerUserId); + add('man_second_sampler_id', secondSampler?['user_id']); + add('man_date', samplingDate); + add('man_time', samplingTime); + add('man_type', samplingType); + add('man_sample_id_code', sampleIdCode); + add('station_id', selectedStation?['station_id']); + add('man_current_latitude', currentLatitude); + add('man_current_longitude', currentLongitude); + add('man_distance_difference', distanceDifferenceInKm); + add('man_distance_difference_remarks', distanceDifferenceRemarks); + + // Step 2 Data + add('man_weather', weather); + add('man_tide_level', tideLevel); + add('man_sea_condition', seaCondition); + add('man_event_remark', eventRemarks); + add('man_lab_remark', labRemarks); + add('man_optional_photo_01_remarks', optionalRemark1); + add('man_optional_photo_02_remarks', optionalRemark2); + add('man_optional_photo_03_remarks', optionalRemark3); + add('man_optional_photo_04_remarks', optionalRemark4); + + // Step 3 Data + add('man_sondeID', sondeId); + add('data_capture_date', dataCaptureDate); + add('data_capture_time', dataCaptureTime); + add('man_oxygen_conc', oxygenConcentration); + add('man_oxygen_sat', oxygenSaturation); + add('man_ph', ph); + add('man_salinity', salinity); + add('man_conductivity', electricalConductivity); + add('man_temperature', temperature); + add('man_tds', tds); + add('man_turbidity', turbidity); + add('man_tss', tss); + add('man_battery_volt', batteryVoltage); + + add('first_sampler_name', firstSamplerName); + add('man_station_code', selectedStation?['man_station_code']); + add('man_station_name', selectedStation?['man_station_name']); + + return map; + } + + /// Converts the image properties into a Map for the multipart API request. + Map toApiImageFiles() { + return { + 'man_left_side_land_view': leftLandViewImage, + 'man_right_side_land_view': rightLandViewImage, + 'man_filling_water_into_sample_bottle': waterFillingImage, + 'man_seawater_in_clear_glass_bottle': seawaterColorImage, + 'man_examine_preservative_ph_paper': phPaperImage, + 'man_optional_photo_01': optionalImage1, + 'man_optional_photo_02': optionalImage2, + 'man_optional_photo_03': optionalImage3, + 'man_optional_photo_04': optionalImage4, + }; + } +} \ No newline at end of file diff --git a/lib/models/river_in_situ_sampling_data.dart b/lib/models/river_in_situ_sampling_data.dart new file mode 100644 index 0000000..bdc1540 --- /dev/null +++ b/lib/models/river_in_situ_sampling_data.dart @@ -0,0 +1,164 @@ +// lib/models/river_in_situ_sampling_data.dart + +import 'dart:io'; + +/// A data model class to hold all information for the multi-step +/// River In-Situ Sampling form. +class RiverInSituSamplingData { + // --- Step 1: Sampling & Station Info --- + String? firstSamplerName; + int? firstSamplerUserId; + Map? secondSampler; + String? samplingDate; + String? samplingTime; + String? samplingType; + String? sampleIdCode; + + String? selectedStateName; + String? selectedCategoryName; + Map? selectedStation; + + String? stationLatitude; + String? stationLongitude; + String? currentLatitude; + String? currentLongitude; + double? distanceDifferenceInKm; + String? distanceDifferenceRemarks; + + // --- Step 2: Site Info & Photos --- + String? weather; + // CHANGED: Renamed for river context + String? waterLevel; + String? riverCondition; + String? eventRemarks; + String? labRemarks; + + // CHANGED: Image descriptions adapted for river context + File? leftBankViewImage; + File? rightBankViewImage; + File? waterFillingImage; + File? waterColorImage; + File? phPaperImage; + + File? optionalImage1; + String? optionalRemark1; + File? optionalImage2; + String? optionalRemark2; + File? optionalImage3; + String? optionalRemark3; + File? optionalImage4; + String? optionalRemark4; + + // --- Step 3: Data Capture --- + String? sondeId; + String? dataCaptureDate; + String? dataCaptureTime; + double? oxygenConcentration; + double? oxygenSaturation; + double? ph; + double? salinity; + double? electricalConductivity; + double? temperature; + double? tds; + double? turbidity; + double? tss; + double? batteryVoltage; + + // --- START: Add your river-specific parameters here --- + // Example: + // double? flowRate; + // --- END: Add your river-specific parameters here --- + + + // --- Post-Submission Status --- + String? submissionStatus; + String? submissionMessage; + String? reportId; + + RiverInSituSamplingData({ + this.samplingDate, + this.samplingTime, + }); + + /// Converts the data model into a Map for the API form data. + Map toApiFormData() { + final Map map = {}; + + void add(String key, dynamic value) { + if (value != null) { + map[key] = value.toString(); + } + } + + // IMPORTANT: The keys below are prefixed with 'r_man_' for river manual sampling. + // Ensure these match your backend API requirements. + + // Step 1 Data + add('first_sampler_user_id', firstSamplerUserId); + add('r_man_second_sampler_id', secondSampler?['user_id']); + add('r_man_date', samplingDate); + add('r_man_time', samplingTime); + add('r_man_type', samplingType); + add('r_man_sample_id_code', sampleIdCode); + add('station_id', selectedStation?['station_id']); + add('r_man_current_latitude', currentLatitude); + add('r_man_current_longitude', currentLongitude); + add('r_man_distance_difference', distanceDifferenceInKm); + add('r_man_distance_difference_remarks', distanceDifferenceRemarks); + + // Step 2 Data + add('r_man_weather', weather); + add('r_man_water_level', waterLevel); + add('r_man_river_condition', riverCondition); + add('r_man_event_remark', eventRemarks); + add('r_man_lab_remark', labRemarks); + add('r_man_optional_photo_01_remarks', optionalRemark1); + add('r_man_optional_photo_02_remarks', optionalRemark2); + add('r_man_optional_photo_03_remarks', optionalRemark3); + add('r_man_optional_photo_04_remarks', optionalRemark4); + + // Step 3 Data + add('r_man_sondeID', sondeId); + add('data_capture_date', dataCaptureDate); + add('data_capture_time', dataCaptureTime); + add('r_man_oxygen_conc', oxygenConcentration); + add('r_man_oxygen_sat', oxygenSaturation); + add('r_man_ph', ph); + add('r_man_salinity', salinity); + add('r_man_conductivity', electricalConductivity); + add('r_man_temperature', temperature); + add('r_man_tds', tds); + add('r_man_turbidity', turbidity); + add('r_man_tss', tss); + add('r_man_battery_volt', batteryVoltage); + + // --- START: Add your new river parameters to the form data map --- + // Example: + // add('r_man_flow_rate', flowRate); + // --- END: Add your new river parameters to the form data map --- + + // Additional data for display or logging, adapted from the original model + add('first_sampler_name', firstSamplerName); + // Assuming river station keys are prefixed with 'r_man_' + add('r_man_station_code', selectedStation?['r_man_station_code']); + add('r_man_station_name', selectedStation?['r_man_station_name']); + + return map; + } + + /// Converts the image properties into a Map for the multipart API request. + Map toApiImageFiles() { + // IMPORTANT: Keys adapted for river context. + return { + 'r_man_left_bank_view': leftBankViewImage, + 'r_man_right_bank_view': rightBankViewImage, + 'r_man_filling_water_into_sample_bottle': waterFillingImage, + 'r_man_water_in_clear_glass_bottle': waterColorImage, + 'r_man_examine_preservative_ph_paper': phPaperImage, + 'r_man_optional_photo_01': optionalImage1, + 'r_man_optional_photo_02': optionalImage2, + 'r_man_optional_photo_03': optionalImage3, + 'r_man_optional_photo_04': optionalImage4, + }; + } +} \ No newline at end of file diff --git a/lib/models/tarball_data.dart b/lib/models/tarball_data.dart new file mode 100644 index 0000000..f73807a --- /dev/null +++ b/lib/models/tarball_data.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +/// This class holds all the data collected across the multi-step tarball sampling form. +/// It acts as a temporary data container that is passed between screens. +class TarballSamplingData { + // --- Step 1 Data: Collected in TarballSamplingStep1 --- + String? firstSampler; + int? firstSamplerUserId; + Map? secondSampler; // Holds the full user map for the 2nd sampler + String? samplingDate; + String? samplingTime; + String? selectedStateName; + String? selectedCategoryName; + Map? selectedStation; // Holds the full station map + String? stationLatitude; + String? stationLongitude; + String? currentLatitude; + String? currentLongitude; + double? distanceDifference; + + // --- Step 2 Data: Collected in TarballSamplingStep2 --- + int? classificationId; // CORRECTED: Only the ID is needed. + File? leftCoastalViewImage; + File? rightCoastalViewImage; + File? verticalLinesImage; + File? horizontalLineImage; + File? optionalImage1; + String? optionalRemark1; + File? optionalImage2; + String? optionalRemark2; + File? optionalImage3; + String? optionalRemark3; + File? optionalImage4; + String? optionalRemark4; + + // --- Step 3 Data: For handling the submission response --- + String? reportId; + String? submissionStatus; + String? submissionMessage; + + /// Converts the form's text and selection data into a Map suitable for JSON encoding. + /// This map will be sent as the body of the first API request. + Map toFormData() { + final Map data = { + // Required fields + 'station_id': selectedStation?['station_id']?.toString() ?? '', + 'sampling_date': samplingDate ?? '', + 'sampling_time': samplingTime ?? '', + + // User ID fields + 'first_sampler_user_id': firstSamplerUserId?.toString() ?? '', + 'second_sampler_user_id': secondSampler?['user_id']?.toString() ?? '', + + // Foreign Key ID for classification + 'classification_id': classificationId?.toString() ?? '', + + // Other nullable fields + 'current_latitude': currentLatitude ?? '', + 'current_longitude': currentLongitude ?? '', + 'distance_difference': distanceDifference?.toString() ?? '', + 'optional_photo_remark_01': optionalRemark1 ?? '', + 'optional_photo_remark_02': optionalRemark2 ?? '', + 'optional_photo_remark_03': optionalRemark3 ?? '', + 'optional_photo_remark_04': optionalRemark4 ?? '', + }; + return data; + } + + /// Gathers all non-null image files into a Map. + /// This map is used to build the multipart request for the second API call (image upload). + Map toImageFiles() { + return { + 'left_side_coastal_view': leftCoastalViewImage, + 'right_side_coastal_view': rightCoastalViewImage, + 'drawing_vertical_lines': verticalLinesImage, + 'drawing_horizontal_line': horizontalLineImage, + 'optional_photo_01': optionalImage1, + 'optional_photo_02': optionalImage2, + 'optional_photo_03': optionalImage3, + 'optional_photo_04': optionalImage4, + }; + } +} diff --git a/lib/screens/air/air_home_page.dart b/lib/screens/air/air_home_page.dart new file mode 100644 index 0000000..f47e117 --- /dev/null +++ b/lib/screens/air/air_home_page.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +// Re-defining SidebarItem here for self-containment, +// ideally this would be in a shared utility file if used across many screens. +class SidebarItem { + final IconData? icon; + final String label; + final String? route; + final List? children; + final bool isParent; + final String? imagePath; + + const SidebarItem({ + this.icon, + required this.label, + this.route, + this.children, + this.isParent = false, + this.imagePath, + }) : assert(icon != null || imagePath != null, 'Either icon or imagePath must be provided'); +} + +class AirHomePage extends StatelessWidget { + const AirHomePage({super.key}); + + // Define Air's sub-menu structure (Manual, Continuous, Investigative) + // This mirrors the structure from collapsible_sidebar.dart for consistency. + final List _airSubMenus = const [ + SidebarItem( + icon: Icons.handshake, // Example icon for Manual + label: "Manual", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'), + SidebarItem(icon: Icons.edit_note, label: "Manual Sampling", route: '/air/manual/manual-sampling'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'), + SidebarItem(icon: Icons.article, label: "Data Log", route: '/air/manual/data-log'), + SidebarItem(icon: Icons.image, label: "Image Request", route: '/air/manual/image-request'), + ], + ), + SidebarItem( + icon: Icons.trending_up, + label: "Continuous", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/continuous/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/air/continuous/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/air/continuous/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/continuous/report'), + ], + ), + SidebarItem( + icon: Icons.search, + label: "Investigative", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/investigative/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/air/investigative/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/air/investigative/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/investigative/report'), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Air Department"), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Explore Air Monitoring Sections", + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + Column( + children: _airSubMenus.map((category) { + return _buildCategorySection(context, category); + }).toList(), + ), + ], + ), + ), + ); + } + + // Method to build each category section (Manual, Continuous, Investigative) + Widget _buildCategorySection(BuildContext context, SidebarItem category) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category header (icon + label) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon(category.icon, size: 30, color: Theme.of(context).iconTheme.color), + const SizedBox(width: 12), + Text( + category.label, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const Divider(height: 24, thickness: 1, color: Colors.white24), // Divider below category title + // Grid of sub-menu items + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, // 3 columns for sub-menu items + crossAxisSpacing: 0.0, // Removed horizontal spacing + mainAxisSpacing: 0.0, // Removed vertical spacing + childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content + ), + itemCount: category.children?.length ?? 0, + itemBuilder: (context, index) { + final subItem = category.children![index]; + return InkWell( + onTap: () { + if (subItem.route != null) { + Navigator.pushNamed(context, subItem.route!); + } + }, + borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid + child: Padding( + padding: const EdgeInsets.all(8.0), // Padding around the row content + child: Row( // Changed from Column to Row + mainAxisAlignment: MainAxisAlignment.start, // Align content to start + children: [ + subItem.icon != null + ? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24 + : const SizedBox.shrink(), + const SizedBox(width: 8), // Space between icon and text (horizontal) + Expanded( // Allow text to take remaining space + child: Text( + subItem.label, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11 + textAlign: TextAlign.left, // Align text to left + overflow: TextOverflow.ellipsis, + maxLines: 1, // Single line for label + ), + ), + ], + ), + ), + ); + }, + ), + const SizedBox(height: 16), // Reduced gap after each category group + ], + ); + } +} diff --git a/lib/screens/air/continuous/air_continuous_dashboard.dart b/lib/screens/air/continuous/air_continuous_dashboard.dart new file mode 100644 index 0000000..5d442d9 --- /dev/null +++ b/lib/screens/air/continuous/air_continuous_dashboard.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class AirContinuousDashboard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Continuous Monitoring")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Continuous Monitoring", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildNavButton(context, "Overview", Icons.info, '/air/continuous/overview'), + _buildNavButton(context, "Entry", Icons.edit, '/air/continuous/entry'), + _buildNavButton(context, "Report", Icons.insert_chart, '/air/continuous/report'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/continuous/entry.dart b/lib/screens/air/continuous/entry.dart new file mode 100644 index 0000000..b99b980 --- /dev/null +++ b/lib/screens/air/continuous/entry.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +class EntryScreen extends StatefulWidget { + @override + State createState() => _EntryScreenState(); +} + +class _EntryScreenState extends State { + final _formKey = GlobalKey(); + String station = ''; + String parameter = ''; + String value = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Continuous Entry")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Enter Monitoring Data", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + TextFormField( + decoration: InputDecoration(labelText: "Station"), + onChanged: (val) => station = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Parameter"), + onChanged: (val) => parameter = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Value"), + keyboardType: TextInputType.number, + onChanged: (val) => value = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + // Save logic here + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Data submitted")), + ); + } + }, + child: Text("Submit"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/continuous/overview.dart b/lib/screens/air/continuous/overview.dart new file mode 100644 index 0000000..02cd337 --- /dev/null +++ b/lib/screens/air/continuous/overview.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class OverviewScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Continuous Overview")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Text( + "This screen provides an overview of continuous air monitoring data, including trends, summaries, and station status.", + style: TextStyle(fontSize: 16), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/continuous/report.dart b/lib/screens/air/continuous/report.dart new file mode 100644 index 0000000..ce3c690 --- /dev/null +++ b/lib/screens/air/continuous/report.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class ReportScreen extends StatelessWidget { + final List> sampleData = [ + {"Station": "Site A", "Parameter": "PM2.5", "Value": "35"}, + {"Station": "Site B", "Parameter": "O3", "Value": "22"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Continuous Report")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Monitoring Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 16), + DataTable( + columns: [ + DataColumn(label: Text("Station")), + DataColumn(label: Text("Parameter")), + DataColumn(label: Text("Value")), + ], + rows: sampleData.map((data) { + return DataRow(cells: [ + DataCell(Text(data["Station"]!)), + DataCell(Text(data["Parameter"]!)), + DataCell(Text(data["Value"]!)), + ]); + }).toList(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/investigative/air_investigative_dashboard.dart b/lib/screens/air/investigative/air_investigative_dashboard.dart new file mode 100644 index 0000000..fc39fed --- /dev/null +++ b/lib/screens/air/investigative/air_investigative_dashboard.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class AirInvestigativeDashboard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Investigative Study")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Investigative Study", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildNavButton(context, "Overview", Icons.info, '/air/investigative/overview'), + _buildNavButton(context, "Entry", Icons.edit, '/air/investigative/entry'), + _buildNavButton(context, "Report", Icons.insert_chart, '/air/investigative/report'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/investigative/entry.dart b/lib/screens/air/investigative/entry.dart new file mode 100644 index 0000000..ce74912 --- /dev/null +++ b/lib/screens/air/investigative/entry.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class EntryScreen extends StatefulWidget { + @override + State createState() => _EntryScreenState(); +} + +class _EntryScreenState extends State { + final _formKey = GlobalKey(); + String location = ''; + String pollutant = ''; + String concentration = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Investigative Entry")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Enter Air Study Data", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + TextFormField( + decoration: InputDecoration(labelText: "Location"), + onChanged: (val) => location = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Pollutant"), + onChanged: (val) => pollutant = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Concentration (µg/m³)"), + keyboardType: TextInputType.number, + onChanged: (val) => concentration = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Air data submitted")), + ); + } + }, + child: Text("Submit"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/investigative/overview.dart b/lib/screens/air/investigative/overview.dart new file mode 100644 index 0000000..4d28e70 --- /dev/null +++ b/lib/screens/air/investigative/overview.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class OverviewScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Investigative Overview")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Text( + "This screen provides an overview of investigative air quality studies. These studies are conducted in response to unusual pollution events, complaints, or targeted assessments. Data may include PM levels, VOCs, and meteorological conditions.", + style: TextStyle(fontSize: 16), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/investigative/report.dart b/lib/screens/air/investigative/report.dart new file mode 100644 index 0000000..1ec4527 --- /dev/null +++ b/lib/screens/air/investigative/report.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class ReportScreen extends StatelessWidget { + final List> sampleData = [ + {"Location": "Zone A", "Pollutant": "PM10", "Concentration": "45 µg/m³"}, + {"Location": "Zone B", "Pollutant": "VOC", "Concentration": "12 µg/m³"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Investigative Report")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Investigative Study Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 16), + DataTable( + columns: [ + DataColumn(label: Text("Location")), + DataColumn(label: Text("Pollutant")), + DataColumn(label: Text("Concentration")), + ], + rows: sampleData.map((data) { + return DataRow(cells: [ + DataCell(Text(data["Location"]!)), + DataCell(Text(data["Pollutant"]!)), + DataCell(Text(data["Concentration"]!)), + ]); + }).toList(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/manual/air_manual_dashboard.dart b/lib/screens/air/manual/air_manual_dashboard.dart new file mode 100644 index 0000000..4ddf26b --- /dev/null +++ b/lib/screens/air/manual/air_manual_dashboard.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class AirManualDashboard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Manual Sampling")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Manual Sampling", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildNavButton(context, "Overview", Icons.info, '/air/manual/overview'), + _buildNavButton(context, "Entry", Icons.edit, '/air/manual/entry'), + _buildNavButton(context, "Report", Icons.insert_chart, '/air/manual/report'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/manual/data_status_log.dart b/lib/screens/air/manual/data_status_log.dart new file mode 100644 index 0000000..4961fae --- /dev/null +++ b/lib/screens/air/manual/data_status_log.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class AirManualDataStatusLog extends StatelessWidget { + final List> logEntries = [ + {"Date": "2025-07-15", "Status": "Submitted", "User": "analyst_air"}, + {"Date": "2025-07-16", "Status": "Approved", "User": "supervisor_air"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Manual Data Status Log")), + body: Padding( + padding: const EdgeInsets.all(24), + child: DataTable( + columns: [ + DataColumn(label: Text("Date")), + DataColumn(label: Text("Status")), + DataColumn(label: Text("User")), + ], + rows: logEntries.map((entry) { + return DataRow(cells: [ + DataCell(Text(entry["Date"]!)), + DataCell(Text(entry["Status"]!)), + DataCell(Text(entry["User"]!)), + ]); + }).toList(), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/manual/image_request.dart b/lib/screens/air/manual/image_request.dart new file mode 100644 index 0000000..8ea2cb1 --- /dev/null +++ b/lib/screens/air/manual/image_request.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; // Add this line at the top of these files + + +class AirManualImageRequest extends StatefulWidget { + @override + State createState() => _AirManualImageRequestState(); +} + +class _AirManualImageRequestState extends State { + XFile? _image; + final picker = ImagePicker(); + final _descriptionController = TextEditingController(); + + Future _pickImage() async { + final pickedFile = await picker.pickImage(source: ImageSource.camera); + setState(() => _image = pickedFile); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Manual Image Request")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + ElevatedButton.icon( + icon: Icon(Icons.camera_alt), + label: Text("Capture Image"), + onPressed: _pickImage, + ), + SizedBox(height: 16), + if (_image != null) + Image.file( + File(_image!.path), + height: 200, + ), + SizedBox(height: 16), + TextField( + controller: _descriptionController, + decoration: InputDecoration(labelText: "Description"), + maxLines: 3, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + // Submit logic here + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Image request submitted")), + ); + }, + child: Text("Submit Request"), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/manual/manual_sampling.dart b/lib/screens/air/manual/manual_sampling.dart new file mode 100644 index 0000000..2d85ce4 --- /dev/null +++ b/lib/screens/air/manual/manual_sampling.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class AirManualSampling extends StatefulWidget { + @override + State createState() => _AirManualSamplingState(); +} + +class _AirManualSamplingState extends State { + final _formKey = GlobalKey(); + String station = ''; + String parameter = ''; + String value = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Manual Sampling")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Enter Sampling Data", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + TextFormField( + decoration: InputDecoration(labelText: "Station"), + onChanged: (val) => station = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Parameter"), + onChanged: (val) => parameter = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Value"), + keyboardType: TextInputType.number, + onChanged: (val) => value = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Air sampling data submitted")), + ); + } + }, + child: Text("Submit"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/air/manual/report.dart b/lib/screens/air/manual/report.dart new file mode 100644 index 0000000..e3e591e --- /dev/null +++ b/lib/screens/air/manual/report.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class AirManualReport extends StatelessWidget { + final List> sampleData = [ + {"Station": "Air Site A", "Parameter": "PM2.5", "Value": "42"}, + {"Station": "Air Site B", "Parameter": "SO2", "Value": "18"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Air Manual Report")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Manual Sampling Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 16), + DataTable( + columns: [ + DataColumn(label: Text("Station")), + DataColumn(label: Text("Parameter")), + DataColumn(label: Text("Value")), + ], + rows: sampleData.map((data) { + return DataRow(cells: [ + DataCell(Text(data["Station"]!)), + DataCell(Text(data["Parameter"]!)), + DataCell(Text(data["Value"]!)), + ]); + }).toList(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/forgot_password.dart b/lib/screens/forgot_password.dart new file mode 100644 index 0000000..1799fad --- /dev/null +++ b/lib/screens/forgot_password.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../auth_provider.dart'; + +class ForgotPasswordScreen extends StatefulWidget { + @override + State createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final _formKey = GlobalKey(); + String email = ''; + String message = ''; + + @override + Widget build(BuildContext context) { + final auth = Provider.of(context); + + return Scaffold( + appBar: AppBar(title: Text("Forgot Password")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Reset Password", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + SizedBox(height: 32), + TextFormField( + decoration: InputDecoration(labelText: "Email"), + keyboardType: TextInputType.emailAddress, + onChanged: (val) => email = val, + validator: (val) => val == null || val.isEmpty ? "Enter your email" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + auth.resetPassword(email); + setState(() => message = "Reset link sent to $email"); + } + }, + child: Text("Send Reset Link"), + ), + if (message.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(message, style: TextStyle(color: Colors.green)), + ), + SizedBox(height: 24), + TextButton( + onPressed: () => Navigator.pushReplacementNamed(context, '/'), + child: Text("Back to Login"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/login.dart b/lib/screens/login.dart new file mode 100644 index 0000000..0663b19 --- /dev/null +++ b/lib/screens/login.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/home_page.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final ApiService _apiService = ApiService(); + bool _isLoading = false; + String _errorMessage = ''; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _login() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + final auth = Provider.of(context, listen: false); + + // --- Offline Check for First Login --- + if (auth.isFirstLogin) { + final connectivityResult = await Connectivity().checkConnectivity(); + if (connectivityResult == ConnectivityResult.none) { + if (!mounted) return; + setState(() { + _isLoading = false; + _errorMessage = 'An internet connection is required for the first login to sync initial data.'; + }); + _showSnackBar(_errorMessage, isError: true); + return; + } + } + + // --- API Call --- + final Map result = await _apiService.login( + _emailController.text.trim(), + _passwordController.text.trim(), + ); + + if (!mounted) return; + + if (result['success'] == true) { + // --- Update AuthProvider --- + final String token = result['data']['token']; + // CORRECTED: The API now returns a 'profile' object on login, not 'user'. + final Map profile = result['data']['profile']; + + // The login method in AuthProvider now handles setting the token, profile, + // and triggering the full data sync. + await auth.login(token, profile); + + if (auth.isFirstLogin) { + await auth.setIsFirstLogin(false); + } + + if (!mounted) return; + + // Navigate to the home screen + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const HomePage()), + ); + + } else { + // Login failed, show error message + setState(() { + _isLoading = false; + _errorMessage = result['message'] ?? 'An unknown error occurred.'; + }); + _showSnackBar(_errorMessage, isError: true); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Theme.of(context).colorScheme.error : null, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Login'), + centerTitle: true, + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "PSTW MMS V4", + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 32), + Center( + child: Image.asset( + 'assets/icon_3_512x512.png', + height: 120, + width: 120, + ), + ), + const SizedBox(height: 48), + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: "Email"), + keyboardType: TextInputType.emailAddress, + validator: (val) => val == null || val.isEmpty ? "Enter your email" : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration(labelText: "Password"), + obscureText: true, + validator: (val) => val == null || val.length < 6 ? "Minimum 6 characters" : null, + ), + const SizedBox(height: 24), + _isLoading + ? const CircularProgressIndicator() + : ElevatedButton( + onPressed: _login, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + 'Login', + style: TextStyle(fontSize: 18), + ), + ), + if (_errorMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _errorMessage, + style: TextStyle(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 15), + TextButton( + onPressed: () { + Navigator.pushNamed(context, '/forgot-password'); + }, + child: const Text('Forgot Password?'), + ), + TextButton( + onPressed: () { + Navigator.pushNamed(context, '/register'); + }, + child: const Text('Don\'t have an account? Register'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/logout.dart b/lib/screens/logout.dart new file mode 100644 index 0000000..e538d26 --- /dev/null +++ b/lib/screens/logout.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../auth_provider.dart'; + +class LogoutScreen extends StatelessWidget { + const LogoutScreen({super.key}); + + @override + Widget build(BuildContext context) { + final auth = Provider.of(context, listen: false); + + // Use addPostFrameCallback to ensure the dialog is shown after the widget + // has been built, preventing errors like "setState() called during build". + WidgetsBinding.instance.addPostFrameCallback((_) { + // Check if a dialog is already open to prevent multiple dialogs + // This is a simple check, more robust solutions might involve a state variable + // if this screen could be rebuilt frequently without navigation. + if (Navigator.of(context).canPop() && ModalRoute.of(context)?.isCurrent == true) { + showDialog( + context: context, + barrierDismissible: false, // User must choose an action (Cancel/Logout) + builder: (dialogContext) => AlertDialog( + title: const Text("Are you sure?"), + content: const Text("Do you really want to log out?"), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(dialogContext); // Close dialog + // Go back to the previous screen if user cancels logout + Navigator.pop(context); + }, + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(dialogContext); // Close dialog + auth.logout(); + // Navigate to login screen and remove all routes below it + Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[700], + foregroundColor: Colors.white, + ), + child: const Text("Logout"), + ), + ], + ), + ); + } + }); + + // The LogoutScreen itself can be an empty Scaffold, as its primary + // purpose is to trigger the dialog immediately upon navigation. + return Scaffold( + appBar: AppBar(title: const Text("Logout")), + body: const Center( + child: CircularProgressIndicator(), // Show a loading indicator briefly + ), + ); + } +} diff --git a/lib/screens/marine/continuous/entry.dart b/lib/screens/marine/continuous/entry.dart new file mode 100644 index 0000000..439b385 --- /dev/null +++ b/lib/screens/marine/continuous/entry.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class EntryScreen extends StatefulWidget { + @override + State createState() => _EntryScreenState(); +} + +class _EntryScreenState extends State { + final _formKey = GlobalKey(); + String station = ''; + String parameter = ''; + String value = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Continuous Entry")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Enter Monitoring Data", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + TextFormField( + decoration: InputDecoration(labelText: "Station"), + onChanged: (val) => station = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Parameter"), + onChanged: (val) => parameter = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Value"), + keyboardType: TextInputType.number, + onChanged: (val) => value = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Data submitted")), + ); + } + }, + child: Text("Submit"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/continuous/marine_continuous_dashboard.dart b/lib/screens/marine/continuous/marine_continuous_dashboard.dart new file mode 100644 index 0000000..873ee09 --- /dev/null +++ b/lib/screens/marine/continuous/marine_continuous_dashboard.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class MarineContinuousDashboard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Continuous Monitoring")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Continuous Monitoring", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildNavButton(context, "Overview", Icons.info, '/marine/continuous/overview'), + _buildNavButton(context, "Entry", Icons.edit, '/marine/continuous/entry'), + _buildNavButton(context, "Report", Icons.insert_chart, '/marine/continuous/report'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/continuous/overview.dart b/lib/screens/marine/continuous/overview.dart new file mode 100644 index 0000000..9bb23e5 --- /dev/null +++ b/lib/screens/marine/continuous/overview.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class OverviewScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Continuous Overview")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Text( + "This screen provides an overview of continuous marine monitoring data, including salinity, turbidity, and station status.", + style: TextStyle(fontSize: 16), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/continuous/report.dart b/lib/screens/marine/continuous/report.dart new file mode 100644 index 0000000..6135677 --- /dev/null +++ b/lib/screens/marine/continuous/report.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class ReportScreen extends StatelessWidget { + final List> sampleData = [ + {"Station": "Marine Site A", "Parameter": "Salinity", "Value": "35 PSU"}, + {"Station": "Marine Site B", "Parameter": "Turbidity", "Value": "4 NTU"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Continuous Report")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Monitoring Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 16), + DataTable( + columns: [ + DataColumn(label: Text("Station")), + DataColumn(label: Text("Parameter")), + DataColumn(label: Text("Value")), + ], + rows: sampleData.map((data) { + return DataRow(cells: [ + DataCell(Text(data["Station"]!)), + DataCell(Text(data["Parameter"]!)), + DataCell(Text(data["Value"]!)), + ]); + }).toList(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/investigative/entry.dart b/lib/screens/marine/investigative/entry.dart new file mode 100644 index 0000000..cc73fda --- /dev/null +++ b/lib/screens/marine/investigative/entry.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class EntryScreen extends StatefulWidget { + @override + State createState() => _EntryScreenState(); +} + +class _EntryScreenState extends State { + final _formKey = GlobalKey(); + String zone = ''; + String observation = ''; + String severity = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Investigative Entry")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Enter Marine Study Data", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + TextFormField( + decoration: InputDecoration(labelText: "Zone"), + onChanged: (val) => zone = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Observation"), + onChanged: (val) => observation = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Severity Level"), + onChanged: (val) => severity = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Marine data submitted")), + ); + } + }, + child: Text("Submit"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/investigative/marine_investigative_dashboard.dart b/lib/screens/marine/investigative/marine_investigative_dashboard.dart new file mode 100644 index 0000000..80516ab --- /dev/null +++ b/lib/screens/marine/investigative/marine_investigative_dashboard.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class MarineInvestigativeDashboard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Investigative Study")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Investigative Study", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildNavButton(context, "Overview", Icons.info, '/marine/investigative/overview'), + _buildNavButton(context, "Entry", Icons.edit, '/marine/investigative/entry'), + _buildNavButton(context, "Report", Icons.insert_chart, '/marine/investigative/report'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/investigative/overview.dart b/lib/screens/marine/investigative/overview.dart new file mode 100644 index 0000000..b2e6074 --- /dev/null +++ b/lib/screens/marine/investigative/overview.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class OverviewScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Investigative Overview")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Text( + "This screen provides an overview of investigative marine studies. These are conducted to assess oil spills, tarball sightings, or unusual biological activity. Data may include salinity, turbidity, hydrocarbon levels, and visual observations.", + style: TextStyle(fontSize: 16), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/investigative/report.dart b/lib/screens/marine/investigative/report.dart new file mode 100644 index 0000000..55de68a --- /dev/null +++ b/lib/screens/marine/investigative/report.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class ReportScreen extends StatelessWidget { + final List> sampleData = [ + {"Zone": "Coast A", "Observation": "Oil sheen", "Severity": "High"}, + {"Zone": "Coast B", "Observation": "Tarball", "Severity": "Moderate"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Investigative Report")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Investigative Study Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 16), + DataTable( + columns: [ + DataColumn(label: Text("Zone")), + DataColumn(label: Text("Observation")), + DataColumn(label: Text("Severity")), + ], + rows: sampleData.map((data) { + return DataRow(cells: [ + DataCell(Text(data["Zone"]!)), + DataCell(Text(data["Observation"]!)), + DataCell(Text(data["Severity"]!)), + ]); + }).toList(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/data_status_log.dart b/lib/screens/marine/manual/data_status_log.dart new file mode 100644 index 0000000..45c81a9 --- /dev/null +++ b/lib/screens/marine/manual/data_status_log.dart @@ -0,0 +1,407 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:environment_monitoring_app/models/tarball_data.dart'; +import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; // Import In-Situ model +import 'package:environment_monitoring_app/services/local_storage_service.dart'; +import 'package:environment_monitoring_app/services/marine_api_service.dart'; + +// A unified model to represent any type of submission log entry. +class SubmissionLogEntry { + final String type; // e.g., 'tarball', 'in-situ' + final String title; + final String stationCode; + final DateTime submissionDateTime; + final String? reportId; + final String status; + final String message; + final Map rawData; + bool isResubmitting; + + SubmissionLogEntry({ + required this.type, + required this.title, + required this.stationCode, + required this.submissionDateTime, + this.reportId, + required this.status, + required this.message, + required this.rawData, + this.isResubmitting = false, + }); +} + +class MarineManualDataStatusLog extends StatefulWidget { + const MarineManualDataStatusLog({super.key}); + + @override + State createState() => _MarineManualDataStatusLogState(); +} + +class _MarineManualDataStatusLogState extends State { + final LocalStorageService _localStorageService = LocalStorageService(); + final MarineApiService _marineApiService = MarineApiService(); + + Map> _groupedLogs = {}; + Map> _filteredLogs = {}; + final Map _isCategoryExpanded = {}; + + bool _isLoading = true; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadAllLogs(); + _searchController.addListener(_filterLogs); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + /// Loads logs from all available modules (Tarball, In-Situ, etc.) + Future _loadAllLogs() async { + setState(() => _isLoading = true); + + // --- Fetch logs for all types --- + final tarballLogs = await _localStorageService.getAllTarballLogs(); + final inSituLogs = await _localStorageService.getAllInSituLogs(); + + final Map> tempGroupedLogs = {}; + + // Map tarball logs (Unchanged) + final List tarballEntries = []; + for (var log in tarballLogs) { + tarballEntries.add(SubmissionLogEntry( + type: 'Tarball Sampling', + title: log['selectedStation']?['tbl_station_name'] ?? 'Unknown Station', + stationCode: log['selectedStation']?['tbl_station_code'] ?? 'N/A', + submissionDateTime: DateTime.tryParse('${log['sampling_date']} ${log['sampling_time']}') ?? DateTime.now(), + reportId: log['reportId']?.toString(), + status: log['submissionStatus'] ?? 'L1', + message: log['submissionMessage'] ?? 'No status message.', + rawData: log, + )); + } + if (tarballEntries.isNotEmpty) { + tarballEntries.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempGroupedLogs['Tarball Sampling'] = tarballEntries; + } + + // --- Map In-Situ logs --- + final List inSituEntries = []; + for (var log in inSituLogs) { + // REPAIRED: Use the correct date/time keys for In-Situ data. + final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? ''; + final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? ''; + + inSituEntries.add(SubmissionLogEntry( + type: 'In-Situ Sampling', + title: log['selectedStation']?['man_station_name'] ?? 'Unknown Station', + stationCode: log['selectedStation']?['man_station_code'] ?? 'N/A', + submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(), + reportId: log['reportId']?.toString(), + status: log['submissionStatus'] ?? 'L1', + message: log['submissionMessage'] ?? 'No status message.', + rawData: log, + )); + } + if (inSituEntries.isNotEmpty) { + inSituEntries.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempGroupedLogs['In-Situ Sampling'] = inSituEntries; + } + // --- END of In-Situ mapping --- + + if (mounted) { + setState(() { + _groupedLogs = tempGroupedLogs; + _filteredLogs = tempGroupedLogs; + _isLoading = false; + }); + } + } + + void _filterLogs() { + final query = _searchController.text.toLowerCase(); + final Map> tempFiltered = {}; + + _groupedLogs.forEach((category, logs) { + final filtered = logs.where((log) { + return log.title.toLowerCase().contains(query) || + log.stationCode.toLowerCase().contains(query) || + (log.reportId?.toLowerCase() ?? '').contains(query); + }).toList(); + + if (filtered.isNotEmpty) { + tempFiltered[category] = filtered; + } + }); + + setState(() { + _filteredLogs = tempFiltered; + }); + } + + /// Main router for resubmitting data based on its type. + Future _resubmitData(SubmissionLogEntry log) async { + setState(() => log.isResubmitting = true); + + switch (log.type) { + case 'Tarball Sampling': + await _resubmitTarballData(log); + break; + case 'In-Situ Sampling': + await _resubmitInSituData(log); + break; + default: + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Resubmission for '${log.type}' is not implemented."), backgroundColor: Colors.orange), + ); + setState(() => log.isResubmitting = false); + } + } + } + + /// Handles resubmission for Tarball data. (Unchanged) + Future _resubmitTarballData(SubmissionLogEntry log) async { + final logData = log.rawData; + + final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? ''); + final int? classificationId = int.tryParse(logData['classification_id']?.toString() ?? ''); + + final TarballSamplingData dataToResubmit = TarballSamplingData() + ..selectedStation = logData['selectedStation'] + ..samplingDate = logData['sampling_date'] + ..samplingTime = logData['sampling_time'] + ..firstSamplerUserId = firstSamplerId + ..secondSampler = logData['secondSampler'] + ..classificationId = classificationId + ..currentLatitude = logData['current_latitude']?.toString() + ..currentLongitude = logData['current_longitude']?.toString() + ..distanceDifference = logData['distance_difference'] + ..optionalRemark1 = logData['optional_photo_remark_01'] + ..optionalRemark2 = logData['optional_photo_remark_02'] + ..optionalRemark3 = logData['optional_photo_remark_03'] + ..optionalRemark4 = logData['optional_photo_remark_04']; + + final Map imageFiles = {}; + final imageKeys = ['left_side_coastal_view', 'right_side_coastal_view', 'drawing_vertical_lines', 'drawing_horizontal_line', 'optional_photo_01', 'optional_photo_02', 'optional_photo_03', 'optional_photo_04']; + for (var key in imageKeys) { + final imagePath = logData[key]; + if (imagePath != null && imagePath.isNotEmpty) { + final file = File(imagePath); + if (await file.exists()) imageFiles[key] = file; + } + } + + final result = await _marineApiService.submitTarballSample(formData: dataToResubmit.toFormData(), imageFiles: imageFiles); + + logData['submissionStatus'] = result['status']; + logData['submissionMessage'] = result['message']; + logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; + await _localStorageService.updateTarballLog(logData); + + if (mounted) await _loadAllLogs(); + } + + /// Handles resubmission for In-Situ data. (Unchanged) + Future _resubmitInSituData(SubmissionLogEntry log) async { + final logData = log.rawData; + + // Reconstruct the InSituSamplingData object from the raw map + final InSituSamplingData dataToResubmit = InSituSamplingData() + ..firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '') + ..secondSampler = logData['secondSampler'] + ..samplingDate = logData['sampling_date'] + ..samplingTime = logData['sampling_time'] + ..samplingType = logData['sampling_type'] + ..sampleIdCode = logData['sample_id_code'] + ..selectedStation = logData['selectedStation'] + ..currentLatitude = logData['current_latitude']?.toString() + ..currentLongitude = logData['current_longitude']?.toString() + ..distanceDifferenceInKm = double.tryParse(logData['distance_difference']?.toString() ?? '0.0') + ..weather = logData['weather'] + ..tideLevel = logData['tide_level'] + ..seaCondition = logData['sea_condition'] + ..eventRemarks = logData['event_remarks'] + ..labRemarks = logData['lab_remarks'] + ..optionalRemark1 = logData['optional_photo_remark_1'] + ..optionalRemark2 = logData['optional_photo_remark_2'] + ..optionalRemark3 = logData['optional_photo_remark_3'] + ..optionalRemark4 = logData['optional_photo_remark_4'] + ..sondeId = logData['sonde_id'] + ..dataCaptureDate = logData['data_capture_date'] + ..dataCaptureTime = logData['data_capture_time'] + ..oxygenConcentration = double.tryParse(logData['oxygen_concentration_mg_l']?.toString() ?? '0.0') + ..oxygenSaturation = double.tryParse(logData['oxygen_saturation_percent']?.toString() ?? '0.0') + ..ph = double.tryParse(logData['ph']?.toString() ?? '0.0') + ..salinity = double.tryParse(logData['salinity_ppt']?.toString() ?? '0.0') + ..electricalConductivity = double.tryParse(logData['ec_us_cm']?.toString() ?? '0.0') + ..temperature = double.tryParse(logData['temperature_c']?.toString() ?? '0.0') + ..tds = double.tryParse(logData['tds_mg_l']?.toString() ?? '0.0') + ..turbidity = double.tryParse(logData['turbidity_ntu']?.toString() ?? '0.0') + ..tss = double.tryParse(logData['tss_mg_l']?.toString() ?? '0.0') + ..batteryVoltage = double.tryParse(logData['battery_v']?.toString() ?? '0.0'); + + // Reconstruct image files + final Map imageFiles = {}; + // Use the keys from the model to ensure consistency + final imageKeys = dataToResubmit.toApiImageFiles().keys; + for (var key in imageKeys) { + final imagePath = logData[key]; + if (imagePath is String && imagePath.isNotEmpty) { + final file = File(imagePath); + if (await file.exists()) { + imageFiles[key] = file; + } + } + } + + // Submit the data via the API service + final result = await _marineApiService.submitInSituSample( + formData: dataToResubmit.toApiFormData(), + imageFiles: imageFiles, + ); + + // Update the local log file with the new submission status + logData['submissionStatus'] = result['status']; + logData['submissionMessage'] = result['message']; + logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; + + // Use the correct update method for In-Situ logs + await _localStorageService.updateInSituLog(logData); + + // Reload logs to refresh the UI + if (mounted) await _loadAllLogs(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Marine Manual Data Status Log')), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: 'Search Logs...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12.0)), + ), + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _loadAllLogs, + child: _filteredLogs.isEmpty + ? Center(child: Text(_groupedLogs.isEmpty ? 'No submission logs found.' : 'No logs match your search.')) + : ListView( + children: _filteredLogs.entries.map((entry) { + return _buildCategorySection(entry.key, entry.value); + }).toList(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCategorySection(String category, List logs) { + final bool isExpanded = _isCategoryExpanded[category] ?? false; + final int itemCount = isExpanded ? logs.length : (logs.length > 5 ? 5 : logs.length); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + const Divider(), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: itemCount, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, + ), + if (logs.length > 5) + TextButton( + onPressed: () { + setState(() { + _isCategoryExpanded[category] = !isExpanded; + }); + }, + child: Text(isExpanded ? 'Show Less' : 'Show More (${logs.length - 5} more)'), + ), + ], + ), + ), + ); + } + + Widget _buildLogListItem(SubmissionLogEntry log) { + final isFailed = log.status != 'L3'; + final title = '${log.title} (${log.stationCode})'; + final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime); + + return ExpansionTile( + leading: Icon( + isFailed ? Icons.error_outline : Icons.check_circle_outline, + color: isFailed ? Colors.red : Colors.green, + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(subtitle), + trailing: isFailed + ? (log.isResubmitting + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 3), + ) + : IconButton( + icon: const Icon(Icons.sync, color: Colors.blue), + tooltip: 'Resubmit', + onPressed: () => _resubmitData(log), + )) + : null, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), + _buildDetailRow('Status:', log.message), + _buildDetailRow('Submission Type:', log.type), + ], + ), + ) + ], + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)), + Expanded(child: Text(value)), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/image_request.dart b/lib/screens/marine/manual/image_request.dart new file mode 100644 index 0000000..30073ae --- /dev/null +++ b/lib/screens/marine/manual/image_request.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; // Add this line at the top of these files + + +class MarineManualImageRequest extends StatefulWidget { + @override + State createState() => _MarineManualImageRequestState(); +} + +class _MarineManualImageRequestState extends State { + XFile? _image; + final picker = ImagePicker(); + final _descriptionController = TextEditingController(); + + Future _pickImage() async { + final pickedFile = await picker.pickImage(source: ImageSource.camera); + setState(() => _image = pickedFile); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Manual Image Request")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + ElevatedButton.icon( + icon: Icon(Icons.camera_alt), + label: Text("Capture Image"), + onPressed: _pickImage, + ), + SizedBox(height: 16), + if (_image != null) + Image.file( + File(_image!.path), + height: 200, + ), + SizedBox(height: 16), + TextField( + controller: _descriptionController, + decoration: InputDecoration(labelText: "Description"), + maxLines: 3, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + // Submit logic here + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Image request submitted")), + ); + }, + child: Text("Submit Request"), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/in_situ_sampling.dart b/lib/screens/marine/manual/in_situ_sampling.dart new file mode 100644 index 0000000..9eb8074 --- /dev/null +++ b/lib/screens/marine/manual/in_situ_sampling.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; // ADDED: Import for date formatting +import 'package:provider/provider.dart'; + +import '../../../models/in_situ_sampling_data.dart'; +import '../../../services/in_situ_sampling_service.dart'; +import '../../../services/local_storage_service.dart'; +import 'widgets/in_situ_step_1_sampling_info.dart'; +import 'widgets/in_situ_step_2_site_info.dart'; +import 'widgets/in_situ_step_3_data_capture.dart'; +import 'widgets/in_situ_step_4_summary.dart'; + +/// The main screen for the In-Situ Sampling feature. +/// This stateful widget orchestrates the multi-step process using a PageView. +/// It manages the overall data model and the service layer for the entire workflow. +class MarineInSituSampling extends StatefulWidget { + const MarineInSituSampling({super.key}); + + @override + State createState() => _MarineInSituSamplingState(); +} + +class _MarineInSituSamplingState extends State { + final PageController _pageController = PageController(); + + // REPAIRED: Changed from `final` to `late` to allow re-initialization. + late InSituSamplingData _data; + + // A single instance of the service to be used by all child widgets. + final InSituSamplingService _samplingService = InSituSamplingService(); + + // Service for saving submission logs locally. + final LocalStorageService _localStorageService = LocalStorageService(); + + int _currentPage = 0; + bool _isLoading = false; + + // ADDED: initState to create a fresh data object each time the widget is created. + @override + void initState() { + super.initState(); + // This is the core of the fix. It creates a NEW data object with the + // CURRENT date and time every time the user starts a new sampling. + _data = InSituSamplingData( + samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), + samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()), + ); + } + + @override + void dispose() { + _pageController.dispose(); + // Dispose the service to clean up its resources (e.g., stream controllers). + _samplingService.dispose(); + super.dispose(); + } + + /// Navigates to the next page in the form. + void _nextPage() { + if (_currentPage < 3) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + /// Navigates to the previous page in the form. + void _previousPage() { + if (_currentPage > 0) { + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + /// Handles the final submission process. + Future _submitForm() async { + setState(() => _isLoading = true); + + // Use the service to submit the data. + final result = await _samplingService.submitData(_data); + + if (!mounted) return; + + // Update the data model with the submission result. + _data.submissionStatus = result['status']; + _data.submissionMessage = result['message']; + _data.reportId = result['reportId']?.toString(); + + // Save a log of the submission locally. + await _localStorageService.saveInSituSamplingData(_data); + + setState(() => _isLoading = false); + + // Show feedback to the user based on the result. + final message = result['message'] ?? 'An unknown error occurred.'; + final color = (result['status'] == 'L3') + ? Colors.green + : (result['status'] == 'L2' ? Colors.orange : Colors.red); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), + ); + + // If submission was fully successful, navigate back to the home screen. + if (result['status'] == 'L3') { + Navigator.of(context).popUntil((route) => route.isFirst); + } + } + + @override + Widget build(BuildContext context) { + // Use Provider.value to provide the existing service instance to all child widgets. + return Provider.value( + value: _samplingService, + child: Scaffold( + appBar: AppBar( + title: Text('In-Situ Sampling (${_currentPage + 1}/4)'), + // Show a back button on all pages except the first one. + leading: _currentPage > 0 + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _previousPage, + ) + : null, + ), + body: PageView( + controller: _pageController, + // Disable manual swiping between pages. + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + children: [ + // Each step is a separate widget, receiving the data model and navigation callbacks. + InSituStep1SamplingInfo(data: _data, onNext: _nextPage), + InSituStep2SiteInfo(data: _data, onNext: _nextPage), + InSituStep3DataCapture(data: _data, onNext: _nextPage), + InSituStep4Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/info_centre_document.dart b/lib/screens/marine/manual/info_centre_document.dart new file mode 100644 index 0000000..e42df24 --- /dev/null +++ b/lib/screens/marine/manual/info_centre_document.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; + +class MarineInfoCentreDocument extends StatefulWidget { + @override + State createState() => _MarineInfoCentreDocumentState(); +} + +class _MarineInfoCentreDocumentState extends State { + String? selectedFileName; + + Future _pickDocument() async { + final result = await FilePicker.platform.pickFiles(); + if (result != null && result.files.isNotEmpty) { + setState(() => selectedFileName = result.files.first.name); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Document '${result.files.first.name}' selected")), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Info Centre Document")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Upload or View Reference Documents", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + ElevatedButton.icon( + icon: Icon(Icons.upload_file), + label: Text("Select Document"), + onPressed: _pickDocument, + ), + SizedBox(height: 16), + if (selectedFileName != null) + Text("Selected: $selectedFileName", style: TextStyle(fontSize: 16)), + SizedBox(height: 24), + ElevatedButton( + onPressed: selectedFileName != null + ? () { + // Submit logic here + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Document submitted")), + ); + } + : null, + child: Text("Submit Document"), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/marine_manual_dashboard.dart b/lib/screens/marine/manual/marine_manual_dashboard.dart new file mode 100644 index 0000000..2602da3 --- /dev/null +++ b/lib/screens/marine/manual/marine_manual_dashboard.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class MarineManualDashboard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Manual Sampling")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Manual Sampling", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildNavButton(context, "Overview", Icons.info, '/marine/manual/overview'), + _buildNavButton(context, "Entry", Icons.edit, '/marine/manual/entry'), + _buildNavButton(context, "Report", Icons.insert_chart, '/marine/manual/report'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/pre_sampling.dart b/lib/screens/marine/manual/pre_sampling.dart new file mode 100644 index 0000000..e11552c --- /dev/null +++ b/lib/screens/marine/manual/pre_sampling.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class MarinePreSampling extends StatefulWidget { + @override + State createState() => _MarinePreSamplingState(); +} + +class _MarinePreSamplingState extends State { + final _formKey = GlobalKey(); + String site = ''; + String weather = ''; + String tide = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Pre-Sampling")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Enter Pre-Sampling Conditions", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + TextFormField( + decoration: InputDecoration(labelText: "Site"), + onChanged: (val) => site = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Weather"), + onChanged: (val) => weather = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Tide Condition"), + onChanged: (val) => tide = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Pre-sampling data submitted")), + ); + } + }, + child: Text("Submit"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/report.dart b/lib/screens/marine/manual/report.dart new file mode 100644 index 0000000..7018953 --- /dev/null +++ b/lib/screens/marine/manual/report.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class MarineManualReport extends StatelessWidget { + final List> sampleData = [ + {"Station": "Marine Site A", "Parameter": "Salinity", "Value": "34 PSU"}, + {"Station": "Marine Site B", "Parameter": "Turbidity", "Value": "5 NTU"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Marine Manual Report")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Manual Sampling Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 16), + DataTable( + columns: [ + DataColumn(label: Text("Station")), + DataColumn(label: Text("Parameter")), + DataColumn(label: Text("Value")), + ], + rows: sampleData.map((data) { + return DataRow(cells: [ + DataCell(Text(data["Station"]!)), + DataCell(Text(data["Parameter"]!)), + DataCell(Text(data["Value"]!)), + ]); + }).toList(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/tarball_sampling.dart b/lib/screens/marine/manual/tarball_sampling.dart new file mode 100644 index 0000000..39c83c8 --- /dev/null +++ b/lib/screens/marine/manual/tarball_sampling.dart @@ -0,0 +1,156 @@ +// lib/screens/marine/manual/tarball_sampling.dart + +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:image/image.dart' as img; + +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/services/marine_api_service.dart'; +import 'package:environment_monitoring_app/models/tarball_data.dart'; + +class MarineTarballSampling extends StatefulWidget { + const MarineTarballSampling({super.key}); + + @override + State createState() => _MarineTarballSamplingState(); +} + +class _MarineTarballSamplingState extends State { + final _formKey1 = GlobalKey(); + final _formKey2 = GlobalKey(); + int _currentStep = 1; + + final MarineApiService _marineApiService = MarineApiService(); + bool _isLoading = false; + + final TarballSamplingData _data = TarballSamplingData(); + + List _statesList = []; + List _categoriesForState = []; + List> _stationsForCategory = []; + + @override + void initState() { + super.initState(); + _initializeForm(); + } + + void _initializeForm() { + final auth = Provider.of(context, listen: false); + _data.firstSampler = auth.profileData?['first_name'] ?? 'Current User'; + final now = DateTime.now(); + _data.samplingDate = DateFormat('yyyy-MM-dd').format(now); + _data.samplingTime = DateFormat('HH:mm:ss').format(now); + + final allStations = auth.tarballStations ?? []; + if (allStations.isNotEmpty) { + final states = allStations.map((s) => s['state_name'] as String?).whereType().toSet().toList(); + states.sort(); + _statesList = states; + } + } + + Future _pickAndProcessImage(ImageSource source) async { + final picker = ImagePicker(); + final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); + if (photo == null) return null; + + final bytes = await photo.readAsBytes(); + img.Image? originalImage = img.decodeImage(bytes); + if (originalImage == null) return null; + + final String timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); + + // Shadow for visibility + img.drawString(originalImage, timestamp, font: img.arial24, x: 11, y: 11, color: img.ColorRgb8(0, 0, 0)); + // Foreground text + img.drawString(originalImage, timestamp, font: img.arial24, x: 10, y: 10, color: img.ColorRgb8(255, 255, 255)); + + final tempDir = await getTemporaryDirectory(); + final filePath = path.join(tempDir.path, '${DateTime.now().millisecondsSinceEpoch}.jpg'); + return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); + } + + void _setImage(Function(File?) setImageCallback, ImageSource source) async { + final file = await _pickAndProcessImage(source); + if (file != null) setState(() => setImageCallback(file)); + } + + Future _getCurrentLocation() async { /* ... Location logic ... */ } + void _calculateDistance() { /* ... Distance logic ... */ } + + Future _submitForm() async { + setState(() => _isLoading = true); + + final result = await _marineApiService.submitTarballSample( + formData: _data.toFormData(), + imageFiles: _data.toImageFiles(), + ); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (result['success'] == true) { + _showSnackBar("Data submitted successfully!"); + Navigator.of(context).pop(); + } else { + _showSnackBar("Submission failed: ${result['message']}"); + } + } + + void _showSnackBar(String message) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Marine Tarball Sampling")), + body: Stepper( + currentStep: _currentStep - 1, + onStepContinue: () { + if (_currentStep == 1 && _formKey1.currentState!.validate()) { + _formKey1.currentState!.save(); + setState(() => _currentStep++); + } else if (_currentStep == 2 && _formKey2.currentState!.validate()) { + _formKey2.currentState!.save(); + setState(() => _currentStep++); + } + }, + onStepCancel: () { + if (_currentStep > 1) setState(() => _currentStep--); + }, + controlsBuilder: (context, details) { + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Row( + children: [ + if (_currentStep < 3) ElevatedButton(onPressed: details.onStepContinue, child: const Text('Next')), + if (_currentStep == 3) ElevatedButton(onPressed: _submitForm, child: const Text('Submit')), + if (_currentStep > 1) TextButton(onPressed: details.onStepCancel, child: const Text('Back')), + ], + ), + ); + }, + steps: [ + Step(title: const Text('Sampling Info'), content: _buildForm1(), isActive: _currentStep >= 1), + Step(title: const Text('On-Site Info'), content: _buildForm2(), isActive: _currentStep >= 2), + Step(title: const Text('Summary'), content: _buildForm3(), isActive: _currentStep >= 3), + ], + ), + ); + } + + Widget _buildForm1() { /* ... UI for Step 1 ... */ return Container(); } + Widget _buildForm2() { /* ... UI for Step 2 ... */ return Container(); } + Widget _buildForm3() { /* ... UI for Step 3 ... */ return Container(); } +} diff --git a/lib/screens/marine/manual/tarball_sampling_step1.dart b/lib/screens/marine/manual/tarball_sampling_step1.dart new file mode 100644 index 0000000..66bb186 --- /dev/null +++ b/lib/screens/marine/manual/tarball_sampling_step1.dart @@ -0,0 +1,319 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; +import 'package:intl/intl.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/models/tarball_data.dart'; +import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step2.dart'; + +class TarballSamplingStep1 extends StatefulWidget { + const TarballSamplingStep1({super.key}); + + @override + State createState() => _TarballSamplingStep1State(); +} + +class _TarballSamplingStep1State extends State { + final _formKey = GlobalKey(); + final _data = TarballSamplingData(); + bool _isLoading = false; + + // --- Controllers for UI fields --- + final TextEditingController _firstSamplerController = TextEditingController(); + final TextEditingController _dateController = TextEditingController(); + final TextEditingController _timeController = TextEditingController(); + final TextEditingController _stationLatController = TextEditingController(); + final TextEditingController _stationLonController = TextEditingController(); + final TextEditingController _currentLatController = TextEditingController(); + final TextEditingController _currentLonController = TextEditingController(); + + // --- State for Dropdowns and Location --- + List _statesList = []; + List _categoriesForState = []; + List> _stationsForCategory = []; + double? _distanceDifference; + + @override + void initState() { + super.initState(); + _initializeForm(); + } + + @override + void dispose() { + // Dispose all controllers to prevent memory leaks + _firstSamplerController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + _stationLatController.dispose(); + _stationLonController.dispose(); + _currentLatController.dispose(); + _currentLonController.dispose(); + super.dispose(); + } + + void _initializeForm() { + final auth = Provider.of(context, listen: false); + + // Set initial values for the data model and controllers + // This relies on the AuthProvider having been populated with data, + // which works offline if the data was fetched and cached previously. + _data.firstSampler = auth.profileData?['first_name'] ?? 'Current User'; + _data.firstSamplerUserId = auth.profileData?['user_id']; + _firstSamplerController.text = _data.firstSampler!; + + final now = DateTime.now(); + _data.samplingDate = DateFormat('yyyy-MM-dd').format(now); + _data.samplingTime = DateFormat('HH:mm:ss').format(now); + _dateController.text = _data.samplingDate!; + _timeController.text = _data.samplingTime!; + + // Populate the initial list of unique states from all available stations. + // This also relies on cached data in AuthProvider for offline use. + final allStations = auth.tarballStations ?? []; + if (allStations.isNotEmpty) { + final states = allStations.map((s) => s['state_name'] as String?).whereType().toSet().toList(); + states.sort(); + _statesList = states; + } + } + + /// Fetches the device's location with an offline-first approach. + Future _getCurrentLocation() async { + bool serviceEnabled; + LocationPermission permission; + + // Check if location services are enabled. + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + _showSnackBar('Location services are disabled. Please enable them.'); + return; + } + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + _showSnackBar('Location permissions are denied.'); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + _showSnackBar('Location permissions are permanently denied. We cannot request permissions.'); + return; + } + + setState(() => _isLoading = true); + + try { + // --- OFFLINE-FIRST LOGIC --- + // 1. Try to get the last known position. This is fast and works offline. + Position? position = await Geolocator.getLastKnownPosition(); + + // 2. If no last known position, get the current position using GPS. + // This can work offline but may take longer. + position ??= await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); + + if (mounted) { + setState(() { + _data.currentLatitude = position!.latitude.toString(); + _data.currentLongitude = position.longitude.toString(); + _currentLatController.text = _data.currentLatitude!; + _currentLonController.text = _data.currentLongitude!; + _calculateDistance(); + }); + } + } catch (e) { + _showSnackBar('Failed to get location: $e'); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + void _calculateDistance() { + if (_stationLatController.text.isNotEmpty && _currentLatController.text.isNotEmpty) { + final double lat1 = double.parse(_stationLatController.text); + final double lon1 = double.parse(_stationLonController.text); + final double lat2 = double.parse(_currentLatController.text); + final double lon2 = double.parse(_currentLonController.text); + + double distanceInMeters = Geolocator.distanceBetween(lat1, lon1, lat2, lon2); + setState(() { + _distanceDifference = distanceInMeters / 1000; + _data.distanceDifference = _distanceDifference; + }); + } + } + + void _goToNextStep() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => TarballSamplingStep2(data: _data)), + ); + } + } + + void _showSnackBar(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + } + + @override + Widget build(BuildContext context) { + // For offline functionality, all data used in the dropdowns (tarballStations, allUsers) + // must be fetched and cached in the AuthProvider when the app is online. + final auth = Provider.of(context, listen: false); + final allStations = auth.tarballStations ?? []; + + final currentUser = auth.profileData; + final allUsers = auth.allUsers ?? []; + final secondSamplersList = allUsers.where((user) => user['user_id'] != currentUser?['user_id']).toList(); + + return Scaffold( + appBar: AppBar(title: const Text("Tarball Sampling (1/3)")), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')), + const SizedBox(height: 16), + + DropdownSearch>( + // --- CORRECTED: Added a ValueKey to prevent the "Duplicate GlobalKey" error --- + // This ensures the widget rebuilds cleanly when its item list changes. + key: ValueKey(allUsers.length), + items: secondSamplersList, + itemAsString: (sampler) => "${sampler['first_name']} ${sampler['last_name']}", + onChanged: (sampler) => setState(() => _data.secondSampler = sampler), + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Sampler...")), + ), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: InputDecoration(labelText: '2nd Sampler (Optional)'), + ), + ), + + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))), + const SizedBox(width: 16), + Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))), + ], + ), + const SizedBox(height: 16), + DropdownSearch( + items: _statesList, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")), + onChanged: (state) { + setState(() { + _data.selectedStateName = state; + _data.selectedCategoryName = null; + _data.selectedStation = null; + _stationLatController.clear(); + _stationLonController.clear(); + _distanceDifference = null; + + if (state != null) { + _categoriesForState = allStations.where((s) => s['state_name'] == state).map((s) => s['category_name'] as String?).whereType().toSet().toList(); + _categoriesForState.sort(); + } else { + _categoriesForState = []; + } + _stationsForCategory = []; + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 16), + DropdownSearch( + items: _categoriesForState, + enabled: _data.selectedStateName != null, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *")), + onChanged: (category) { + setState(() { + _data.selectedCategoryName = category; + _data.selectedStation = null; + _stationLatController.clear(); + _stationLonController.clear(); + _distanceDifference = null; + + if (category != null) { + _stationsForCategory = allStations.where((s) => s['state_name'] == _data.selectedStateName && s['category_name'] == category).toList(); + } else { + _stationsForCategory = []; + } + }); + }, + validator: (val) => _data.selectedStateName != null && val == null ? "Category is required" : null, + ), + const SizedBox(height: 16), + DropdownSearch>( + items: _stationsForCategory, + enabled: _data.selectedCategoryName != null, + itemAsString: (station) => "${station['tbl_station_code']} - ${station['tbl_station_name']}", + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *")), + onChanged: (station) => setState(() { + _data.selectedStation = station; + _data.stationLatitude = station?['tbl_latitude']?.toString(); + _data.stationLongitude = station?['tbl_longitude']?.toString(); + _stationLatController.text = _data.stationLatitude ?? ''; + _stationLonController.text = _data.stationLongitude ?? ''; + _calculateDistance(); + }), + validator: (val) => _data.selectedCategoryName != null && val == null ? "Station is required" : null, + onSaved: (station) => _data.selectedStation = station, + ), + const SizedBox(height: 16), + TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')), + const SizedBox(height: 16), + TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')), + const SizedBox(height: 24), + Text("Location Verification", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')), + const SizedBox(height: 16), + TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')), + if (_distanceDifference != null) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text('Distance from Station: ${_distanceDifference!.toStringAsFixed(2)} km', + style: TextStyle( + fontWeight: FontWeight.bold, + color: _distanceDifference! > 1.0 ? Colors.red : Colors.green, + ) + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _isLoading ? null : _getCurrentLocation, + icon: _isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching), + label: const Text("Get Current Location"), + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _goToNextStep, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('Next'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/marine/manual/tarball_sampling_step2.dart b/lib/screens/marine/manual/tarball_sampling_step2.dart new file mode 100644 index 0000000..8b1dd61 --- /dev/null +++ b/lib/screens/marine/manual/tarball_sampling_step2.dart @@ -0,0 +1,330 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:image/image.dart' as img; + +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/models/tarball_data.dart'; +import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step3_summary.dart'; + +class TarballSamplingStep2 extends StatefulWidget { + final TarballSamplingData data; + const TarballSamplingStep2({super.key, required this.data}); + + @override + State createState() => _TarballSamplingStep2State(); +} + +class _TarballSamplingStep2State extends State { + final _formKey = GlobalKey(); + bool _isPickingImage = false; + + Map? _selectedClassification; + + late final TextEditingController _remark1Controller; + late final TextEditingController _remark2Controller; + late final TextEditingController _remark3Controller; + late final TextEditingController _remark4Controller; + + @override + void initState() { + super.initState(); + + _remark1Controller = TextEditingController(text: widget.data.optionalRemark1); + _remark2Controller = TextEditingController(text: widget.data.optionalRemark2); + _remark3Controller = TextEditingController(text: widget.data.optionalRemark3); + _remark4Controller = TextEditingController(text: widget.data.optionalRemark4); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.data.classificationId != null) { + final auth = Provider.of(context, listen: false); + final classifications = auth.tarballClassifications ?? []; + if (classifications.isNotEmpty) { + try { + final foundClassification = classifications.firstWhere( + (c) => c['classification_id'] == widget.data.classificationId, + ); + if (mounted) { + setState(() { + _selectedClassification = foundClassification; + }); + } + } catch (e) { + debugPrint("Could not find pre-selected classification with ID: ${widget.data.classificationId}"); + } + } + } + }); + } + + @override + void dispose() { + _remark1Controller.dispose(); + _remark2Controller.dispose(); + _remark3Controller.dispose(); + _remark4Controller.dispose(); + super.dispose(); + } + + /// Shows a dialog to the user informing them about the image orientation requirement. + void _showOrientationDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text("Incorrect Image Orientation"), + content: const Text("Required photos must be taken in a horizontal (landscape) orientation."), + actions: [ + TextButton( + child: const Text("OK"), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + }, + ); + } + + /// Picks an image, processes it (checks orientation, adds watermark), and returns the file. + Future _pickAndProcessImage(ImageSource source, String imageInfo, {required bool isRequired}) async { + if (_isPickingImage) return null; + setState(() => _isPickingImage = true); + + final picker = ImagePicker(); + final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); + + if (photo == null) { + setState(() => _isPickingImage = false); + return null; + } + + final bytes = await photo.readAsBytes(); + img.Image? originalImage = img.decodeImage(bytes); + if (originalImage == null) { + setState(() => _isPickingImage = false); + return null; + } + + // --- NEW: Validate image orientation for required photos --- + if (isRequired && originalImage.height > originalImage.width) { + _showOrientationDialog(); + setState(() => _isPickingImage = false); + return null; // Reject the vertical image + } + + // --- MODIFIED: Reduced watermark font size --- + final String watermarkTimestamp = "${widget.data.samplingDate} ${widget.data.samplingTime}"; + final font = img.arial24; // Reduced from arial48 + const int padding = 10; + + final textWidth = watermarkTimestamp.length * 12; + final textHeight = 24; + img.fillRect( + originalImage, + x1: padding - 5, y1: padding - 5, + x2: padding + textWidth + 5, y2: padding + textHeight + 5, + color: img.ColorRgb8(255, 255, 255), + ); + + img.drawString( + originalImage, + watermarkTimestamp, + font: font, + x: padding, y: padding, + color: img.ColorRgb8(0, 0, 0), + ); + + final stationCode = widget.data.selectedStation?['tbl_station_code'] ?? 'NO_STATION'; + final fileTimestamp = "${widget.data.samplingDate}-${widget.data.samplingTime}".replaceAll(':', '-'); + final sanitizedImageInfo = imageInfo.replaceAll(' ', '').toUpperCase(); + final newFileName = "${stationCode}_${fileTimestamp}_${sanitizedImageInfo}.jpg"; + + final tempDir = await getTemporaryDirectory(); + final filePath = path.join(tempDir.path, newFileName); + final processedFile = await File(filePath).writeAsBytes(img.encodeJpg(originalImage)); + + setState(() => _isPickingImage = false); + return processedFile; + } + + void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async { + final file = await _pickAndProcessImage(source, imageInfo, isRequired: isRequired); + if (file != null) { + setState(() { + setImageCallback(file); + }); + } + } + + void _goToNextStep() { + if (_formKey.currentState!.validate()) { + // --- NEW: Validate that a classification has been selected --- + if (widget.data.classificationId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please select a tarball classification before proceeding.'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // --- NEW: Validate that all required photos have been attached --- + if (widget.data.leftCoastalViewImage == null || + widget.data.rightCoastalViewImage == null || + widget.data.verticalLinesImage == null || + widget.data.horizontalLineImage == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please attach all required photos before proceeding.'), + backgroundColor: Colors.red, + ), + ); + return; // Stop the function if validation fails + } + + widget.data.optionalRemark1 = _remark1Controller.text; + widget.data.optionalRemark2 = _remark2Controller.text; + widget.data.optionalRemark3 = _remark3Controller.text; + widget.data.optionalRemark4 = _remark4Controller.text; + + Navigator.push( + context, + MaterialPageRoute(builder: (context) => TarballSamplingStep3Summary(data: widget.data)), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("On-Site Info (2/3)")), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + + Consumer( + builder: (context, auth, child) { + if (auth.tarballClassifications == null || auth.tarballClassifications!.isEmpty) { + return DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Tarball Classification *', + hintText: 'Loading or no classifications found...', + ), + items: const [], + onChanged: null, + ); + } + + return DropdownButtonFormField>( + decoration: const InputDecoration(labelText: 'Tarball Classification *'), + value: _selectedClassification, + items: auth.tarballClassifications!.map((classification) { + return DropdownMenuItem>( + value: classification, + child: Text(classification['classification_name']?.toString() ?? 'Unnamed'), + ); + }).toList(), + onChanged: (value) { + setState(() { + _selectedClassification = value; + widget.data.classificationId = value?['classification_id']; + }); + }, + validator: (value) => value == null ? 'Classification is required' : null, + ); + }, + ), + + const SizedBox(height: 24), + // --- MODIFIED: Added asterisk to indicate required section --- + Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), + _buildImagePicker('Left Side Coastal View', 'LEFTSIDECOASTALVIEW', widget.data.leftCoastalViewImage, (file) => widget.data.leftCoastalViewImage = file, isRequired: true), + _buildImagePicker('Right Side Coastal View', 'RIGHTSIDECOASTALVIEW', widget.data.rightCoastalViewImage, (file) => widget.data.rightCoastalViewImage = file, isRequired: true), + _buildImagePicker('Drawing Vertical Lines', 'VERTICALLINES', widget.data.verticalLinesImage, (file) => widget.data.verticalLinesImage = file, isRequired: true), + _buildImagePicker('Drawing Horizontal Line', 'HORIZONTALLINE', widget.data.horizontalLineImage, (file) => widget.data.horizontalLineImage = file, isRequired: true), + + const SizedBox(height: 24), + Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + _buildImagePicker('Optional Photo 1', 'OPTIONAL1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _remark1Controller), + _buildImagePicker('Optional Photo 2', 'OPTIONAL2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _remark2Controller), + _buildImagePicker('Optional Photo 3', 'OPTIONAL3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _remark3Controller), + _buildImagePicker('Optional Photo 4', 'OPTIONAL4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _remark4Controller), + + const SizedBox(height: 32), + ElevatedButton( + onPressed: _goToNextStep, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('Next'), + ), + ], + ), + ), + ); + } + + // --- MODIFIED: Added isRequired flag --- + Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- MODIFIED: Add asterisk to title if required --- + Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + if (imageFile != null) + Stack( + alignment: Alignment.topRight, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover) + ), + Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + shape: BoxShape.circle, + ), + child: IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, color: Colors.white, size: 20), + onPressed: () => setState(() => setImageCallback(null)), + ), + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")), + ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), + ], + ), + if (remarkController != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextFormField( + controller: remarkController, + decoration: InputDecoration( + labelText: 'Remarks for $title', + hintText: 'Add an optional remark...', + border: const OutlineInputBorder(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart new file mode 100644 index 0000000..703c613 --- /dev/null +++ b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart @@ -0,0 +1,260 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/models/tarball_data.dart'; +import 'package:environment_monitoring_app/services/marine_api_service.dart'; +import 'package:environment_monitoring_app/services/local_storage_service.dart'; + +class TarballSamplingStep3Summary extends StatefulWidget { + final TarballSamplingData data; + const TarballSamplingStep3Summary({super.key, required this.data}); + + @override + State createState() => _TarballSamplingStep3SummaryState(); +} + +class _TarballSamplingStep3SummaryState extends State { + final MarineApiService _marineApiService = MarineApiService(); + final LocalStorageService _localStorageService = LocalStorageService(); + bool _isLoading = false; + + Future _submitForm() async { + setState(() => _isLoading = true); + + // Step 1: Orchestrated Server Submission + final result = await _marineApiService.submitTarballSample( + formData: widget.data.toFormData(), + imageFiles: widget.data.toImageFiles(), + ); + + if (!mounted) return; + + // Step 2: Update the data model with submission results + widget.data.submissionStatus = result['status']; + widget.data.submissionMessage = result['message']; + widget.data.reportId = result['reportId']?.toString(); + + // Step 3: Local Save with the complete data, including submission status. + final String? localPath = await _localStorageService.saveTarballSamplingData(widget.data); + + if (mounted) { + if (localPath != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Submission log saved locally to: $localPath")), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Warning: Could not save submission log locally."), backgroundColor: Colors.orange), + ); + } + } + + setState(() => _isLoading = false); + + // Step 4: Handle UI feedback based on the final status + final status = result['status']; + final message = result['message'] ?? 'An unknown error occurred.'; + + debugPrint("Submission final status: $status. Report ID: ${widget.data.reportId}. Message: $message"); + + if (status == 'L3') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.green), + ); + Navigator.of(context).popUntil((route) => route.isFirst); + } else { // L1 or L2 failures + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: (status == 'L2' ? Colors.orange : Colors.red), duration: const Duration(seconds: 5)), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Summary & Confirmation (3/3)")), + body: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + Text( + "Please review all information before submitting.", + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + _buildSectionCard( + "Sampling Details", + [ + _buildDetailRow("1st Sampler:", widget.data.firstSampler), + _buildDetailRow("2nd Sampler:", widget.data.secondSampler?['first_name']?.toString()), + _buildDetailRow("Sampling Date:", widget.data.samplingDate), + _buildDetailRow("Sampling Time:", widget.data.samplingTime), + ], + ), + + _buildSectionCard( + "Station Information", + [ + _buildDetailRow("State:", widget.data.selectedStateName), + _buildDetailRow("Category:", widget.data.selectedCategoryName), + _buildDetailRow("Station Code:", widget.data.selectedStation?['tbl_station_code']?.toString()), + _buildDetailRow("Station Name:", widget.data.selectedStation?['tbl_station_name']?.toString()), + _buildDetailRow("Station Latitude:", widget.data.stationLatitude), + _buildDetailRow("Station Longitude:", widget.data.stationLongitude), + ], + ), + + _buildSectionCard( + "Location Verification", + [ + _buildDetailRow("Current Latitude:", widget.data.currentLatitude), + _buildDetailRow("Current Longitude:", widget.data.currentLongitude), + _buildDetailRow("Distance Difference:", + widget.data.distanceDifference != null + ? "${widget.data.distanceDifference!.toStringAsFixed(2)} km" + : "N/A" + ), + ], + ), + + _buildSectionCard( + "On-Site Information & Photos", + [ + // CORRECTED: Look up the classification name from the provider using the ID. + Consumer( + builder: (context, auth, child) { + String classificationName = 'N/A'; + if (widget.data.classificationId != null && auth.tarballClassifications != null) { + try { + final classification = auth.tarballClassifications!.firstWhere( + (c) => c['classification_id'] == widget.data.classificationId, + ); + classificationName = classification['classification_name'] ?? 'ID not found'; + } catch (e) { + classificationName = 'ID: ${widget.data.classificationId}'; + } + } + return _buildDetailRow("Tarball Classification:", classificationName); + }, + ), + const Divider(height: 24), + _buildImageCard("Left Side Coastal View", widget.data.leftCoastalViewImage), + _buildImageCard("Right Side Coastal View", widget.data.rightCoastalViewImage), + _buildImageCard("Drawing Vertical Lines", widget.data.verticalLinesImage), + _buildImageCard("Drawing Horizontal Line", widget.data.horizontalLineImage), + const Divider(height: 24), + Text("Optional Photos", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + _buildImageCard("Optional Photo 1", widget.data.optionalImage1, remark: widget.data.optionalRemark1), + _buildImageCard("Optional Photo 2", widget.data.optionalImage2, remark: widget.data.optionalRemark2), + _buildImageCard("Optional Photo 3", widget.data.optionalImage3, remark: widget.data.optionalRemark3), + _buildImageCard("Optional Photo 4", widget.data.optionalImage4, remark: widget.data.optionalRemark4), + ], + ), + + const SizedBox(height: 24), + _isLoading + ? const Center(child: CircularProgressIndicator()) + : ElevatedButton.icon( + onPressed: _submitForm, + icon: const Icon(Icons.cloud_upload), + label: const Text('Confirm & Submit'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 16), + ], + ), + ); + } + + Widget _buildSectionCard(String title, List children) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8.0), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + const Divider(height: 20, thickness: 1), + ...children, + ], + ), + ), + ); + } + + Widget _buildDetailRow(String label, String? value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: Text( + value != null && value.isNotEmpty ? value : 'N/A', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ); + } + + Widget _buildImageCard(String title, File? image, {String? remark}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + if (image != null) + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.file(image, key: UniqueKey(), height: 200, width: double.infinity, fit: BoxFit.cover), + ) + else + Container( + height: 100, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8.0), + border: Border.all(color: Colors.grey[300]!) + ), + child: const Center(child: Text('No Image Attached', style: TextStyle(color: Colors.grey))), + ), + if (remark != null && remark.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.black54)), + ), + ], + ), + ); + } +} diff --git a/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart new file mode 100644 index 0000000..f37895d --- /dev/null +++ b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart @@ -0,0 +1,443 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; +import 'package:intl/intl.dart'; +import 'package:simple_barcode_scanner/simple_barcode_scanner.dart'; + +import '../../../../auth_provider.dart'; +import '../../../../models/in_situ_sampling_data.dart'; +import '../../../../services/in_situ_sampling_service.dart'; + +class InSituStep1SamplingInfo extends StatefulWidget { + final InSituSamplingData data; + final VoidCallback onNext; + + const InSituStep1SamplingInfo({ + super.key, + required this.data, + required this.onNext, + }); + + @override + State createState() => _InSituStep1SamplingInfoState(); +} + +class _InSituStep1SamplingInfoState extends State { + final _formKey = GlobalKey(); + bool _isLoadingLocation = false; + + late final TextEditingController _firstSamplerController; + late final TextEditingController _dateController; + late final TextEditingController _timeController; + late final TextEditingController _barcodeController; + late final TextEditingController _stationLatController; + late final TextEditingController _stationLonController; + late final TextEditingController _currentLatController; + late final TextEditingController _currentLonController; + + List _statesList = []; + List _categoriesForState = []; + List> _stationsForCategory = []; + final List _samplingTypes = ['Schedule', 'Ad-Hoc', 'Complaint']; + + @override + void initState() { + super.initState(); + _initializeControllers(); + _initializeForm(); + } + + @override + void dispose() { + _firstSamplerController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + _barcodeController.dispose(); + _stationLatController.dispose(); + _stationLonController.dispose(); + _currentLatController.dispose(); + _currentLonController.dispose(); + super.dispose(); + } + + void _initializeControllers() { + _firstSamplerController = TextEditingController(); + _dateController = TextEditingController(); + _timeController = TextEditingController(); + _barcodeController = TextEditingController(text: widget.data.sampleIdCode); + _stationLatController = TextEditingController(text: widget.data.stationLatitude); + _stationLonController = TextEditingController(text: widget.data.stationLongitude); + _currentLatController = TextEditingController(text: widget.data.currentLatitude); + _currentLonController = TextEditingController(text: widget.data.currentLongitude); + } + + void _initializeForm() { + final auth = Provider.of(context, listen: false); + + widget.data.firstSamplerName = auth.profileData?['first_name'] ?? 'Current User'; + widget.data.firstSamplerUserId = auth.profileData?['user_id']; + _firstSamplerController.text = widget.data.firstSamplerName!; + + final now = DateTime.now(); + if (widget.data.samplingDate == null || widget.data.samplingDate!.isEmpty) { + widget.data.samplingDate = DateFormat('yyyy-MM-dd').format(now); + widget.data.samplingTime = DateFormat('HH:mm:ss').format(now); + } + _dateController.text = widget.data.samplingDate!; + _timeController.text = widget.data.samplingTime!; + + final allStations = auth.manualStations ?? []; + if (allStations.isNotEmpty) { + final states = allStations.map((s) => s['state_name'] as String?).whereType().toSet().toList(); + states.sort(); + + if (widget.data.selectedStateName != null) { + final categories = allStations + .where((s) => s['state_name'] == widget.data.selectedStateName) + .map((s) => s['category_name'] as String?) + .whereType() + .toSet() + .toList(); + categories.sort(); + _categoriesForState = categories; + } + if (widget.data.selectedCategoryName != null) { + _stationsForCategory = allStations + .where((s) => + s['state_name'] == widget.data.selectedStateName && + s['category_name'] == widget.data.selectedCategoryName) + .toList(); + } + + setState(() { + _statesList = states; + }); + } + } + + Future _getCurrentLocation() async { + setState(() => _isLoadingLocation = true); + final service = Provider.of(context, listen: false); + + try { + final position = await service.getCurrentLocation(); + if (mounted) { + setState(() { + widget.data.currentLatitude = position.latitude.toString(); + widget.data.currentLongitude = position.longitude.toString(); + _currentLatController.text = widget.data.currentLatitude!; + _currentLonController.text = widget.data.currentLongitude!; + _calculateDistance(); + }); + } + } catch (e) { + if(mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e'))); + } + } finally { + if (mounted) { + setState(() => _isLoadingLocation = false); + } + } + } + + void _calculateDistance() { + final lat1Str = widget.data.stationLatitude; + final lon1Str = widget.data.stationLongitude; + final lat2Str = widget.data.currentLatitude; + final lon2Str = widget.data.currentLongitude; + + if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) { + final service = Provider.of(context, listen: false); + final lat1 = double.tryParse(lat1Str); + final lon1 = double.tryParse(lon1Str); + final lat2 = double.tryParse(lat2Str); + final lon2 = double.tryParse(lon2Str); + + if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) { + final distance = service.calculateDistance(lat1, lon1, lat2, lon2); + setState(() { + widget.data.distanceDifferenceInKm = distance; + }); + } + } + } + + Future _scanBarcode() async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()), + ); + if (result is String && result != '-1' && mounted) { + setState(() { + widget.data.sampleIdCode = result; + _barcodeController.text = result; + }); + } + } + + /// Validates the form and distance, then proceeds to the next step. + void _goToNextStep() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + + final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000; + + if (distanceInMeters > 700) { + _showDistanceRemarkDialog(); + } else { + // If distance is okay, clear any previous remarks and proceed. + widget.data.distanceDifferenceRemarks = null; + widget.onNext(); + } + } + } + + /// Shows a dialog to force the user to enter remarks for large distance differences. + Future _showDistanceRemarkDialog() async { + final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); + final dialogFormKey = GlobalKey(); + + return showDialog( + context: context, + barrierDismissible: false, // User must interact with the dialog + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Distance Warning'), + content: SingleChildScrollView( + child: Form( + key: dialogFormKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Your current location is more than 700m away from the station.'), + const SizedBox(height: 16), + TextFormField( + controller: remarkController, + decoration: const InputDecoration( + labelText: 'Remarks *', + hintText: 'Please provide a reason...', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Remarks are required to continue.'; + } + return null; + }, + maxLines: 3, + ), + ], + ), + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + FilledButton( + child: const Text('Confirm'), + onPressed: () { + if (dialogFormKey.currentState!.validate()) { + setState(() { + widget.data.distanceDifferenceRemarks = remarkController.text; + }); + Navigator.of(context).pop(); + widget.onNext(); // Proceed to next step + } + }, + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final auth = Provider.of(context, listen: false); + final allStations = auth.manualStations ?? []; + final allUsers = auth.allUsers ?? []; + final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList(); + + return Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + // Sampling Information section... (unchanged) + Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')), + const SizedBox(height: 16), + DropdownSearch>( + items: secondSamplersList, + selectedItem: widget.data.secondSampler, + itemAsString: (sampler) => "${sampler['first_name']} ${sampler['last_name']}", + onChanged: (sampler) => widget.data.secondSampler = sampler, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Sampler..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: '2nd Sampler (Optional)')), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))), + const SizedBox(width: 16), + Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))), + ], + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: widget.data.samplingType, + items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(), + onChanged: (value) => setState(() => widget.data.samplingType = value), + decoration: const InputDecoration(labelText: 'Sampling Type *'), + validator: (value) => value == null ? 'Please select a type' : null, + ), + const SizedBox(height: 24), + + // Station Selection section... (unchanged) + DropdownSearch( + items: _statesList, + selectedItem: widget.data.selectedStateName, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")), + onChanged: (state) { + setState(() { + widget.data.selectedStateName = state; + widget.data.selectedCategoryName = null; + widget.data.selectedStation = null; + _stationLatController.clear(); + _stationLonController.clear(); + widget.data.distanceDifferenceInKm = null; + + final categories = state != null + ? allStations + .where((s) => s['state_name'] == state) + .map((s) => s['category_name'] as String?) + .whereType() + .toSet() + .toList() + : []; + categories.sort(); + _categoriesForState = categories; + + _stationsForCategory = []; + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 16), + DropdownSearch( + items: _categoriesForState, + selectedItem: widget.data.selectedCategoryName, + enabled: widget.data.selectedStateName != null, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *")), + onChanged: (category) { + setState(() { + widget.data.selectedCategoryName = category; + widget.data.selectedStation = null; + _stationLatController.clear(); + _stationLonController.clear(); + widget.data.distanceDifferenceInKm = null; + _stationsForCategory = category != null ? allStations.where((s) => s['state_name'] == widget.data.selectedStateName && s['category_name'] == category).toList() : []; + }); + }, + validator: (val) => widget.data.selectedStateName != null && val == null ? "Category is required" : null, + ), + const SizedBox(height: 16), + DropdownSearch>( + items: _stationsForCategory, + selectedItem: widget.data.selectedStation, + enabled: widget.data.selectedCategoryName != null, + itemAsString: (station) => "${station['man_station_code']} - ${station['man_station_name']}", + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *")), + onChanged: (station) => setState(() { + widget.data.selectedStation = station; + widget.data.stationLatitude = station?['man_latitude']?.toString(); + widget.data.stationLongitude = station?['man_longitude']?.toString(); + _stationLatController.text = widget.data.stationLatitude ?? ''; + _stationLonController.text = widget.data.stationLongitude ?? ''; + _calculateDistance(); + }), + validator: (val) => widget.data.selectedCategoryName != null && val == null ? "Station is required" : null, + ), + const SizedBox(height: 16), + TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')), + const SizedBox(height: 16), + TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')), + const SizedBox(height: 24), + + // Location Verification section... + Text("Location Verification", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')), + const SizedBox(height: 16), + TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')), + // MODIFIED: Distance text is now more prominent and styled + if (widget.data.distanceDifferenceInKm != null) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red : Colors.green), + ), + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: Theme.of(context).textTheme.bodyLarge, + children: [ + const TextSpan(text: 'Distance from Station: '), + TextSpan( + text: '${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters', + style: TextStyle( + fontWeight: FontWeight.bold, + color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red : Colors.green + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _isLoadingLocation ? null : _getCurrentLocation, + icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching), + label: const Text("Get Current Location"), + ), + const SizedBox(height: 16), + + TextFormField( + controller: _barcodeController, + decoration: InputDecoration( + labelText: 'Sample ID Code *', + suffixIcon: IconButton( + icon: const Icon(Icons.qr_code_scanner), + onPressed: _scanBarcode, + ), + ), + validator: (val) => val == null || val.isEmpty ? "Sample ID is required" : null, + onSaved: (val) => widget.data.sampleIdCode = val, + onChanged: (val) => widget.data.sampleIdCode = val, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _goToNextStep, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('Next'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart b/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart new file mode 100644 index 0000000..febeb37 --- /dev/null +++ b/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart @@ -0,0 +1,248 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; + +import '../../../../models/in_situ_sampling_data.dart'; +import '../../../../services/in_situ_sampling_service.dart'; + +/// The second step of the In-Situ Sampling form. +/// Gathers on-site conditions (weather, tide) and handles all photo attachments. +class InSituStep2SiteInfo extends StatefulWidget { + final InSituSamplingData data; + final VoidCallback onNext; + + const InSituStep2SiteInfo({ + super.key, + required this.data, + required this.onNext, + }); + + @override + State createState() => _InSituStep2SiteInfoState(); +} + +class _InSituStep2SiteInfoState extends State { + final _formKey = GlobalKey(); + bool _isPickingImage = false; + + // --- UI Controllers for remarks --- + late final TextEditingController _eventRemarksController; + late final TextEditingController _labRemarksController; + late final TextEditingController _optionalRemark1Controller; + late final TextEditingController _optionalRemark2Controller; + late final TextEditingController _optionalRemark3Controller; + late final TextEditingController _optionalRemark4Controller; + + + final List _weatherOptions = ['Clear', 'Rainy', 'Cloudy']; + final List _tideOptions = ['High', 'Low', 'Mid']; + final List _seaConditionOptions = ['Calm', 'Moderate Wave', 'High Wave']; + + @override + void initState() { + super.initState(); + _eventRemarksController = TextEditingController(text: widget.data.eventRemarks); + _labRemarksController = TextEditingController(text: widget.data.labRemarks); + _optionalRemark1Controller = TextEditingController(text: widget.data.optionalRemark1); + _optionalRemark2Controller = TextEditingController(text: widget.data.optionalRemark2); + _optionalRemark3Controller = TextEditingController(text: widget.data.optionalRemark3); + _optionalRemark4Controller = TextEditingController(text: widget.data.optionalRemark4); + } + + @override + void dispose() { + _eventRemarksController.dispose(); + _labRemarksController.dispose(); + _optionalRemark1Controller.dispose(); + _optionalRemark2Controller.dispose(); + _optionalRemark3Controller.dispose(); + _optionalRemark4Controller.dispose(); + super.dispose(); + } + + /// Handles picking and processing an image using the dedicated service. + void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async { + if (_isPickingImage) return; + setState(() => _isPickingImage = true); + + final service = Provider.of(context, listen: false); + + final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired); + + if (file != null) { + setState(() => setImageCallback(file)); + } else if (mounted) { + _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true); + } + + if (mounted) { + setState(() => _isPickingImage = false); + } + } + + /// Validates the form and all required images before proceeding. + void _goToNextStep() { + if (!_formKey.currentState!.validate()) { + return; + } + + if (widget.data.leftLandViewImage == null || + widget.data.rightLandViewImage == null || + widget.data.waterFillingImage == null || + widget.data.seawaterColorImage == null || + widget.data.phPaperImage == null) { + _showSnackBar('Please attach all 5 required photos before proceeding.', isError: true); + return; + } + + _formKey.currentState!.save(); + // --- FIXED: Correctly save remarks text to the data model's remark properties --- + widget.data.optionalRemark1 = _optionalRemark1Controller.text; + widget.data.optionalRemark2 = _optionalRemark2Controller.text; + widget.data.optionalRemark3 = _optionalRemark3Controller.text; + widget.data.optionalRemark4 = _optionalRemark4Controller.text; + widget.onNext(); + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : null, + )); + } + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + // --- Section: On-Site Information --- + Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + DropdownButtonFormField( + value: widget.data.weather, + items: _weatherOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(), + onChanged: (value) => setState(() => widget.data.weather = value), + decoration: const InputDecoration(labelText: 'Weather *'), + validator: (value) => value == null ? 'Weather is required' : null, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: widget.data.tideLevel, + items: _tideOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(), + onChanged: (value) => setState(() => widget.data.tideLevel = value), + decoration: const InputDecoration(labelText: 'Tide Level *'), + validator: (value) => value == null ? 'Tide level is required' : null, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: widget.data.seaCondition, + items: _seaConditionOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(), + onChanged: (value) => setState(() => widget.data.seaCondition = value), + decoration: const InputDecoration(labelText: 'Sea Condition *'), + validator: (value) => value == null ? 'Sea condition is required' : null, + ), + const SizedBox(height: 24), + + // --- Section: Required Photos --- + Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), + const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)), + const SizedBox(height: 8), + _buildImagePicker('Left Side Land View', 'LEFT_LAND_VIEW', widget.data.leftLandViewImage, (file) => widget.data.leftLandViewImage = file, isRequired: true), + _buildImagePicker('Right Side Land View', 'RIGHT_LAND_VIEW', widget.data.rightLandViewImage, (file) => widget.data.rightLandViewImage = file, isRequired: true), + _buildImagePicker('Filling Water into Sample Bottle', 'WATER_FILLING', widget.data.waterFillingImage, (file) => widget.data.waterFillingImage = file, isRequired: true), + _buildImagePicker('Seawater in Clear Glass Bottle', 'SEAWATER_COLOR', widget.data.seawaterColorImage, (file) => widget.data.seawaterColorImage = file, isRequired: true), + _buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: true), + const SizedBox(height: 24), + + // --- Section: Optional Photos --- + Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + _buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: true), + _buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: true), + _buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: true), + _buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: true), + const SizedBox(height: 24), + + // --- Section: Remarks --- + Text("Remarks", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + TextFormField( + controller: _eventRemarksController, + decoration: const InputDecoration(labelText: 'Event Remarks (Optional)', hintText: 'e.g., unusual smells, colors, etc.'), + onSaved: (value) => widget.data.eventRemarks = value, + maxLines: 3, + ), + const SizedBox(height: 16), + TextFormField( + controller: _labRemarksController, + decoration: const InputDecoration(labelText: 'Lab Remarks (Optional)'), + onSaved: (value) => widget.data.labRemarks = value, + maxLines: 3, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _goToNextStep, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('Next'), + ), + ], + ), + ); + } + + /// A reusable widget for picking and displaying an image, matching the tarball design. + Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + if (imageFile != null) + Stack( + alignment: Alignment.topRight, + children: [ + ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)), + Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle), + child: IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, color: Colors.white, size: 20), + onPressed: () => setState(() => setImageCallback(null)), + ), + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")), + ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), + ], + ), + if (remarkController != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextFormField( + controller: remarkController, + decoration: InputDecoration( + labelText: 'Remarks for $title', + hintText: 'Add an optional remark...', + border: const OutlineInputBorder(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart new file mode 100644 index 0000000..d184ac3 --- /dev/null +++ b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart @@ -0,0 +1,500 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; +import 'package:usb_serial/usb_serial.dart'; + +import '../../../../models/in_situ_sampling_data.dart'; +import '../../../../services/in_situ_sampling_service.dart'; +import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum +import '../../../../serial/serial_manager.dart'; // For connection state enum +import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart'; +import '../../../../serial/widget/serial_port_list_dialog.dart'; + +class InSituStep3DataCapture extends StatefulWidget { + final InSituSamplingData data; + final VoidCallback onNext; + + const InSituStep3DataCapture({ + super.key, + required this.data, + required this.onNext, + }); + + @override + State createState() => _InSituStep3DataCaptureState(); +} + +class _InSituStep3DataCaptureState extends State { + final _formKey = GlobalKey(); + bool _isLoading = false; + bool _isAutoReading = false; + StreamSubscription? _dataSubscription; + + final List> _parameters = []; + + final _sondeIdController = TextEditingController(); + final _dateController = TextEditingController(); + final _timeController = TextEditingController(); + final _oxyConcController = TextEditingController(); + final _oxySatController = TextEditingController(); + final _phController = TextEditingController(); + final _salinityController = TextEditingController(); + final _ecController = TextEditingController(); + final _tempController = TextEditingController(); + final _tdsController = TextEditingController(); + final _turbidityController = TextEditingController(); + final _tssController = TextEditingController(); + final _batteryController = TextEditingController(); + + @override + void initState() { + super.initState(); + _initializeControllers(); + } + + @override + void dispose() { + _dataSubscription?.cancel(); + _disposeControllers(); + super.dispose(); + } + + void _initializeControllers() { + // Use the date and time from Step 1 + widget.data.dataCaptureDate = widget.data.samplingDate; + widget.data.dataCaptureTime = widget.data.samplingTime; + + _sondeIdController.text = widget.data.sondeId ?? ''; + _dateController.text = widget.data.dataCaptureDate ?? ''; + _timeController.text = widget.data.dataCaptureTime ?? ''; + + // Set temporary default values to -999 for robust validation. + widget.data.oxygenConcentration ??= -999.0; + widget.data.oxygenSaturation ??= -999.0; + widget.data.ph ??= -999.0; + widget.data.salinity ??= -999.0; + widget.data.electricalConductivity ??= -999.0; + widget.data.temperature ??= -999.0; + widget.data.tds ??= -999.0; + widget.data.turbidity ??= -999.0; + widget.data.tss ??= -999.0; + widget.data.batteryVoltage ??= -999.0; + + _oxyConcController.text = widget.data.oxygenConcentration!.toString(); + _oxySatController.text = widget.data.oxygenSaturation!.toString(); + _phController.text = widget.data.ph!.toString(); + _salinityController.text = widget.data.salinity!.toString(); + _ecController.text = widget.data.electricalConductivity!.toString(); + _tempController.text = widget.data.temperature!.toString(); + _tdsController.text = widget.data.tds!.toString(); + _turbidityController.text = widget.data.turbidity!.toString(); + _tssController.text = widget.data.tss!.toString(); + _batteryController.text = widget.data.batteryVoltage!.toString(); + + // REPAIRED: Reordered parameters to match Step 4 and added icons. + if (_parameters.isEmpty) { + _parameters.addAll([ + {'icon': Icons.air, 'label': 'Oxygen Conc.', 'unit': 'mg/L', 'controller': _oxyConcController}, + {'icon': Icons.percent, 'label': 'Oxygen Sat.', 'unit': '%', 'controller': _oxySatController}, + {'icon': Icons.science_outlined, 'label': 'pH', 'unit': '', 'controller': _phController}, + {'icon': Icons.waves, 'label': 'Salinity', 'unit': 'ppt', 'controller': _salinityController}, + {'icon': Icons.flash_on, 'label': 'Conductivity', 'unit': 'µS/cm', 'controller': _ecController}, + {'icon': Icons.thermostat, 'label': 'Temperature', 'unit': '°C', 'controller': _tempController}, + {'icon': Icons.grain, 'label': 'TDS', 'unit': 'mg/L', 'controller': _tdsController}, + {'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController}, + {'icon': Icons.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController}, + {'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController}, + ]); + } + } + + void _disposeControllers() { + _sondeIdController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + _oxyConcController.dispose(); + _oxySatController.dispose(); + _phController.dispose(); + _salinityController.dispose(); + _ecController.dispose(); + _tempController.dispose(); + _tdsController.dispose(); + _turbidityController.dispose(); + _tssController.dispose(); + _batteryController.dispose(); + } + + /// Handles the entire connection flow, including a permission check. + Future _handleConnectionAttempt(String type) async { + final service = context.read(); + + final bool hasPermissions = await service.requestDevicePermissions(); + if (!hasPermissions && mounted) { + _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); + return; + } + + _disconnectFromAll(); + await Future.delayed(const Duration(milliseconds: 250)); + + final bool connectionSuccess = await _connectToDevice(type); + + if (connectionSuccess && mounted) { + _dataSubscription?.cancel(); + final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream; + + _dataSubscription = stream.listen((readings) { + if (mounted) { + _updateTextFields(readings); + } + }); + } + } + + Future _connectToDevice(String type) async { + setState(() => _isLoading = true); + final service = context.read(); + bool success = false; + + try { + if (type == 'bluetooth') { + final devices = await service.getPairedBluetoothDevices(); + if (devices.isEmpty && mounted) { + _showSnackBar('No paired Bluetooth devices found.', isError: true); + return false; + } + final selectedDevice = await showBluetoothDeviceListDialog(context: context, devices: devices); + if (selectedDevice != null) { + await service.connectToBluetoothDevice(selectedDevice); + success = true; + } + } else if (type == 'serial') { + final devices = await service.getAvailableSerialDevices(); + if (devices.isEmpty && mounted) { + _showSnackBar('No USB Serial devices found.', isError: true); + return false; + } + final selectedDevice = await showSerialPortListDialog(context: context, devices: devices); + if (selectedDevice != null) { + await service.connectToSerialDevice(selectedDevice); + success = true; + } + } + } catch (e) { + if (mounted) _showSnackBar('Connection failed: $e', isError: true); + success = false; + } finally { + if (mounted) setState(() => _isLoading = false); + } + return success; + } + + void _toggleAutoReading(String activeType) { + final service = context.read(); + setState(() { + _isAutoReading = !_isAutoReading; + if (_isAutoReading) { + if (activeType == 'bluetooth') service.startBluetoothAutoReading(); + else service.startSerialAutoReading(); + } else { + if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); + else service.stopSerialAutoReading(); + } + }); + } + + void _disconnect(String type) { + final service = context.read(); + if (type == 'bluetooth') { + service.disconnectFromBluetooth(); + } else { + service.disconnectFromSerial(); + } + _dataSubscription?.cancel(); + _dataSubscription = null; + if (mounted) { + setState(() => _isAutoReading = false); + } + } + + void _disconnectFromAll() { + final service = context.read(); + if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + _disconnect('bluetooth'); + } + if (service.serialConnectionState.value != SerialConnectionState.disconnected) { + _disconnect('serial'); + } + } + + void _updateTextFields(Map readings) { + const defaultValue = -999.0; + setState(() { + _oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5); + _oxySatController.text = (readings['Optical Dissolved Oxygen: Compensated % Saturation'] ?? defaultValue).toStringAsFixed(5); + _phController.text = (readings['PH: PH units'] ?? defaultValue).toStringAsFixed(5); + _tempController.text = (readings['External Temp: Degrees Celcius'] ?? defaultValue).toStringAsFixed(5); + _ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5); + _salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5); + _tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5); + _tssController.text = (readings['Turbidity: TSS'] ?? defaultValue).toStringAsFixed(5); + _turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5); + _batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5); + }); + } + + void _goToNextStep() { + if (_isAutoReading) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Data Collection Active'), + content: const Text('Please stop the live data collection before proceeding.'), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + return; + } + + _formKey.currentState!.save(); + + try { + const defaultValue = -999.0; + widget.data.temperature = double.tryParse(_tempController.text) ?? defaultValue; + widget.data.ph = double.tryParse(_phController.text) ?? defaultValue; + widget.data.salinity = double.tryParse(_salinityController.text) ?? defaultValue; + widget.data.electricalConductivity = double.tryParse(_ecController.text) ?? defaultValue; + widget.data.oxygenConcentration = double.tryParse(_oxyConcController.text) ?? defaultValue; + widget.data.oxygenSaturation = double.tryParse(_oxySatController.text) ?? defaultValue; + widget.data.tds = double.tryParse(_tdsController.text) ?? defaultValue; + widget.data.turbidity = double.tryParse(_turbidityController.text) ?? defaultValue; + widget.data.tss = double.tryParse(_tssController.text) ?? defaultValue; + widget.data.batteryVoltage = double.tryParse(_batteryController.text) ?? defaultValue; + } catch (e) { + _showSnackBar("Could not save parameters due to a data format error.", isError: true); + return; + } + + widget.onNext(); + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : null, + )); + } + } + + Map? _getActiveConnectionDetails() { + final service = context.watch(); + if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + return { + 'type': 'bluetooth', + 'state': service.bluetoothConnectionState.value, + 'name': service.connectedBluetoothDeviceName, + }; + } + if (service.serialConnectionState.value != SerialConnectionState.disconnected) { + return { + 'type': 'serial', + 'state': service.serialConnectionState.value, + 'name': service.connectedSerialDeviceName, + }; + } + return null; + } + + @override + Widget build(BuildContext context) { + final service = context.watch(); + final activeConnection = _getActiveConnectionDetails(); + final String? activeType = activeConnection?['type'] as String?; + + return Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: activeType == 'bluetooth' + ? FilledButton.icon( + icon: const Icon(Icons.bluetooth_connected), + label: const Text("Bluetooth"), + onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'), + ) + : OutlinedButton.icon( + icon: const Icon(Icons.bluetooth), + label: const Text("Bluetooth"), + onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: activeType == 'serial' + ? FilledButton.icon( + icon: const Icon(Icons.usb), + label: const Text("USB Serial"), + onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'), + ) + : OutlinedButton.icon( + icon: const Icon(Icons.usb), + label: const Text("USB Serial"), + onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'), + ), + ), + ], + ), + const SizedBox(height: 16), + if (activeConnection != null) + _buildConnectionCard( + type: activeConnection['type'], + connectionState: activeConnection['state'], + deviceName: activeConnection['name'], + ), + const SizedBox(height: 24), + + ValueListenableBuilder( + valueListenable: service.sondeId, + builder: (context, sondeId, child) { + final newSondeId = sondeId ?? ''; + if (_sondeIdController.text != newSondeId) { + _sondeIdController.text = newSondeId; + widget.data.sondeId = newSondeId; + } + return TextFormField( + controller: _sondeIdController, + decoration: const InputDecoration( + labelText: 'Sonde ID *', + hintText: 'Connect device or enter manually'), + validator: (v) => v!.isEmpty ? 'Sonde ID is required' : null, + onChanged: (value) { + widget.data.sondeId = value; + }, + onSaved: (v) => widget.data.sondeId = v, + ); + }, + ), + + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))), + const SizedBox(width: 16), + Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))), + ], + ), + const Divider(height: 32), + + // REPAIRED: Replaced GridView with a Column of modern list items. + Column( + children: _parameters.map((param) { + return _buildParameterListItem( + icon: param['icon'] as IconData, + label: param['label'] as String, + unit: param['unit'] as String, + controller: param['controller'] as TextEditingController, + ); + }).toList(), + ), + + const SizedBox(height: 32), + ElevatedButton( + onPressed: _goToNextStep, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('Next'), + ), + ], + ), + ); + } + + // ADDED: New helper for modern list item view + Widget _buildParameterListItem({ + required IconData icon, + required String label, + required String unit, + required TextEditingController controller, + }) { + final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); + final String displayValue = isMissing ? '-.--' : controller.text; + final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: ListTile( + leading: Icon(icon, color: Theme.of(context).primaryColor, size: 32), + title: Text(displayLabel), + trailing: Text( + displayValue, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isMissing ? Colors.grey : Theme.of(context).colorScheme.primary), + ), + ), + ); + } + + Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) { + final isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected; + final isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting; + + Color statusColor = isConnected ? Colors.green : Colors.red; + String statusText = isConnected ? 'Connected to ${deviceName ?? 'device'}' : 'Disconnected'; + if (isConnecting) { + statusColor = Colors.orange; + statusText = 'Connecting...'; + } + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text(statusText, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 16), + if (isConnecting || _isLoading) + const CircularProgressIndicator() + else if (isConnected) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), + label: Text(_isAutoReading ? 'Stop Reading' : 'Start Reading'), + onPressed: () => _toggleAutoReading(type), + style: ElevatedButton.styleFrom( + backgroundColor: _isAutoReading ? Colors.orange : Colors.green, + foregroundColor: Colors.white, + ), + ), + TextButton.icon( + icon: const Icon(Icons.link_off), + label: const Text('Disconnect'), + onPressed: () => _disconnect(type), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ) + ], + ) + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart new file mode 100644 index 0000000..a57b6bb --- /dev/null +++ b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart @@ -0,0 +1,225 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; + +import '../../../../models/in_situ_sampling_data.dart'; + +class InSituStep4Summary extends StatelessWidget { + final InSituSamplingData data; + final VoidCallback onSubmit; + final bool isLoading; + + const InSituStep4Summary({ + super.key, + required this.data, + required this.onSubmit, + required this.isLoading, + }); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + Text( + "Please review all information before submitting.", + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + _buildSectionCard( + context, + "Sampling & Station Details", + [ + _buildDetailRow("1st Sampler:", data.firstSamplerName), + _buildDetailRow("2nd Sampler:", data.secondSampler?['first_name']?.toString()), + _buildDetailRow("Sampling Date:", data.samplingDate), + _buildDetailRow("Sampling Time:", data.samplingTime), + _buildDetailRow("Sampling Type:", data.samplingType), + _buildDetailRow("Sample ID Code:", data.sampleIdCode), + const Divider(height: 20), + _buildDetailRow("State:", data.selectedStateName), + _buildDetailRow("Category:", data.selectedCategoryName), + _buildDetailRow("Station Code:", data.selectedStation?['man_station_code']?.toString()), + _buildDetailRow("Station Name:", data.selectedStation?['man_station_name']?.toString()), + _buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"), + ], + ), + + _buildSectionCard( + context, + "Location & On-Site Info", + [ + _buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"), + _buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"), + if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty) + _buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks), + const Divider(height: 20), + _buildDetailRow("Weather:", data.weather), + _buildDetailRow("Tide Level:", data.tideLevel), + _buildDetailRow("Sea Condition:", data.seaCondition), + _buildDetailRow("Event Remarks:", data.eventRemarks), + _buildDetailRow("Lab Remarks:", data.labRemarks), + ], + ), + + _buildSectionCard( + context, + "Attached Photos", + [ + _buildImageCard("Left Side Land View", data.leftLandViewImage), + _buildImageCard("Right Side Land View", data.rightLandViewImage), + _buildImageCard("Filling Water into Bottle", data.waterFillingImage), + _buildImageCard("Seawater Color in Bottle", data.seawaterColorImage), + _buildImageCard("Examine Preservative (pH paper)", data.phPaperImage), + const Divider(height: 24), + Text("Optional Photos", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + _buildImageCard("Optional Photo 1", data.optionalImage1, remark: data.optionalRemark1), + _buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2), + _buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3), + _buildImageCard("Optional Photo 4", data.optionalImage4, remark: data.optionalRemark4), + ], + ), + + _buildSectionCard( + context, + "Captured Parameters", + [ + _buildDetailRow("Sonde ID:", data.sondeId), + _buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"), + const Divider(height: 20), + _buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity?.toStringAsFixed(0)), + _buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage?.toStringAsFixed(2)), + ], + ), + + const SizedBox(height: 24), + isLoading + ? const Center(child: CircularProgressIndicator()) + : ElevatedButton.icon( + onPressed: onSubmit, + icon: const Icon(Icons.cloud_upload), + label: const Text('Confirm & Submit'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 16), + ], + ); + } + + /// A reusable widget to create a consistent section card. + Widget _buildSectionCard(BuildContext context, String title, List children) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8.0), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + const Divider(height: 20, thickness: 1), + ...children, + ], + ), + ), + ); + } + + /// A reusable widget for a label-value row. + Widget _buildDetailRow(String label, String? value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: Text(value != null && value.isNotEmpty ? value : 'N/A', style: const TextStyle(fontSize: 16)), + ), + ], + ), + ); + } + + Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required String? value}) { + // REPAIRED: Only check if the value is null. It will now display numerical + // values like -999.00 instead of converting them to 'N/A'. + final bool isMissing = value == null; + final String displayValue = isMissing ? 'N/A' : '$value ${unit}'.trim(); + + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(icon, color: Theme.of(context).primaryColor, size: 28), + title: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + trailing: Text( + displayValue, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: isMissing ? Colors.grey : null, + fontWeight: isMissing ? null : FontWeight.bold, + ), + ), + ); + } + + /// A reusable widget to display an attached image or a placeholder. + Widget _buildImageCard(String title, File? image, {String? remark}) { + final bool hasRemark = remark != null && remark.isNotEmpty; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + if (image != null) + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.file(image, key: UniqueKey(), height: 200, width: double.infinity, fit: BoxFit.cover), + ) + else + Container( + height: 100, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8.0), + border: Border.all(color: Colors.grey[300]!)), + child: const Center(child: Text('No Image Attached', style: TextStyle(color: Colors.grey))), + ), + if (hasRemark) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic)), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/marine_home_page.dart b/lib/screens/marine/marine_home_page.dart new file mode 100644 index 0000000..53b7fd0 --- /dev/null +++ b/lib/screens/marine/marine_home_page.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; + +// Re-defining SidebarItem here for self-containment, +// ideally this would be in a shared utility file if used across many screens. +class SidebarItem { + final IconData? icon; + final String label; + final String? route; + final List? children; + final bool isParent; + final String? imagePath; + + const SidebarItem({ + this.icon, + required this.label, + this.route, + this.children, + this.isParent = false, + this.imagePath, + }) : assert(icon != null || imagePath != null, 'Either icon or imagePath must be provided'); +} + +class MarineHomePage extends StatelessWidget { + const MarineHomePage({super.key}); + + // Define Marine's sub-menu structure (Manual, Continuous, Investigative) + final List _marineSubMenus = const [ + SidebarItem( + icon: Icons.handshake, + label: "Manual", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/manual/dashboard'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/manual/info'), + SidebarItem(icon: Icons.assignment, label: "Pre-Sampling", route: '/marine/manual/pre-sampling'), + SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/marine/manual/in-situ'), + SidebarItem(icon: Icons.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'), + SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'), + SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'), + ], + ), + SidebarItem( + icon: Icons.trending_up, + label: "Continuous", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/continuous/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/continuous/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/continuous/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/continuous/report'), + ], + ), + SidebarItem( + icon: Icons.search, + label: "Investigative", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/investigative/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/investigative/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Marine Department"), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Explore Marine Monitoring Sections", + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + Column( + children: _marineSubMenus.map((category) { + return _buildCategorySection(context, category); + }).toList(), + ), + ], + ), + ), + ); + } + + // Method to build each category section (Manual, Continuous, Investigative) + Widget _buildCategorySection(BuildContext context, SidebarItem category) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category header (icon + label) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon(category.icon, size: 30, color: Theme.of(context).iconTheme.color), + const SizedBox(width: 12), + Text( + category.label, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const Divider(height: 24, thickness: 1, color: Colors.white24), // Divider below category title + // Grid of sub-menu items + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, // 3 columns for sub-menu items + crossAxisSpacing: 0.0, // Removed horizontal spacing + mainAxisSpacing: 0.0, // Removed vertical spacing + childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content + ), + itemCount: category.children?.length ?? 0, + itemBuilder: (context, index) { + final subItem = category.children![index]; + return InkWell( + onTap: () { + if (subItem.route != null) { + Navigator.pushNamed(context, subItem.route!); + } + }, + borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid + child: Padding( + padding: const EdgeInsets.all(8.0), // Padding around the row content + child: Row( // Changed from Column to Row + mainAxisAlignment: MainAxisAlignment.start, // Align content to start + children: [ + subItem.icon != null + ? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24 + : const SizedBox.shrink(), + const SizedBox(width: 8), // Space between icon and text (horizontal) + Expanded( // Allow text to take remaining space + child: Text( + subItem.label, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11 + textAlign: TextAlign.left, // Align text to left + overflow: TextOverflow.ellipsis, + maxLines: 1, // Single line for label + ), + ), + ], + ), + ), + ); + }, + ), + const SizedBox(height: 16), // Reduced gap after each category group + ], + ); + } +} diff --git a/lib/screens/profile.dart b/lib/screens/profile.dart new file mode 100644 index 0000000..e14b6d6 --- /dev/null +++ b/lib/screens/profile.dart @@ -0,0 +1,357 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/services/api_service.dart'; + +class ProfileScreen extends StatefulWidget { + const ProfileScreen({super.key}); + + @override + State createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends State { + final ApiService _apiService = ApiService(); + bool _isLoading = false; + String _errorMessage = ''; + File? _profileImageFile; + + @override + void initState() { + super.initState(); + // Load the image from cache first, then refresh data from the provider + _loadLocalProfileImage().then((_) { + // If no profile data is available at all, trigger a refresh + if (Provider.of(context, listen: false).profileData == null) { + _refreshProfile(); + } + }); + } + + /// Refreshes only the profile data using the dedicated provider method. + Future _refreshProfile() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final auth = Provider.of(context, listen: false); + // Call the efficient refreshProfile method instead of the full sync. + await auth.refreshProfile(); + // After syncing, reload the potentially new profile image + await _loadLocalProfileImage(); + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'An unexpected error occurred during sync: ${e.toString()}'; + }); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + /// Loads the profile image from the local cache or downloads it if not present. + Future _loadLocalProfileImage() async { + final auth = Provider.of(context, listen: false); + final String? serverImagePath = auth.profileData?['profile_picture']; + + if (serverImagePath != null && serverImagePath.isNotEmpty) { + final String localFileName = p.basename(serverImagePath); + final Directory appDocDir = await getApplicationDocumentsDirectory(); + final String localFilePath = p.join(appDocDir.path, 'profile_pictures', localFileName); + final File localFile = File(localFilePath); + + if (await localFile.exists()) { + if (mounted) setState(() => _profileImageFile = localFile); + } else { + final String fullImageUrl = ApiService.imageBaseUrl + serverImagePath; + final downloadedFile = await _apiService.downloadProfilePicture(fullImageUrl, localFilePath); + if (downloadedFile != null && mounted) { + setState(() => _profileImageFile = downloadedFile); + } + } + } else { + if (mounted) setState(() => _profileImageFile = null); + } + } + + /// Shows a modal bottom sheet for selecting an image source. + Future _showImageSourceSelection() async { + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Take a photo'), + onTap: () { + Navigator.pop(context); + _pickAndUploadImage(ImageSource.camera); + }, + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Choose from gallery'), + onTap: () { + Navigator.pop(context); + _pickAndUploadImage(ImageSource.gallery); + }, + ), + ], + ), + ); + }, + ); + } + + /// Picks an image and initiates the upload process. + Future _pickAndUploadImage(ImageSource source) async { + final ImagePicker picker = ImagePicker(); + final XFile? pickedFile = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); + + if (pickedFile != null) { + setState(() => _isLoading = true); + final File imageFile = File(pickedFile.path); + + final uploadResult = await _apiService.uploadProfilePicture(imageFile); + + if (mounted) { + if (uploadResult['success']) { + // After a successful upload, efficiently refresh only the profile data. + await _refreshProfile(); + _showSnackBar("Profile picture updated successfully.", isError: false); + } else { + setState(() { + _errorMessage = uploadResult['message'] ?? 'Failed to upload profile picture.'; + }); + _showSnackBar(_errorMessage, isError: true); + } + setState(() => _isLoading = false); + } + } + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Theme.of(context).colorScheme.error : Colors.green, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final auth = Provider.of(context); + final profileData = auth.profileData; + + return Scaffold( + appBar: AppBar( + title: const Text("User Profile"), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _isLoading ? null : _refreshProfile, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: _isLoading && profileData == null + ? const Center(child: CircularProgressIndicator()) + : _errorMessage.isNotEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 50), + const SizedBox(height: 16), + Text( + _errorMessage, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 16), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _isLoading ? null : _refreshProfile, + child: const Text('Retry'), + ), + ], + ), + ), + ) + : profileData == null + ? const Center(child: Text('No profile data available.')) + : RefreshIndicator( + onRefresh: _refreshProfile, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: GestureDetector( + onTap: _isLoading ? null : _showImageSourceSelection, + child: Stack( + alignment: Alignment.bottomRight, + children: [ + CircleAvatar( + radius: 60, + backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundImage: _profileImageFile != null ? FileImage(_profileImageFile!) : null, + child: _profileImageFile == null ? Icon(Icons.person, size: 80, color: Theme.of(context).colorScheme.onSecondary) : null, + ), + if (!_isLoading) + Positioned( + right: 0, + bottom: 0, + child: CircleAvatar( + radius: 20, + backgroundColor: Theme.of(context).primaryColor, + child: Icon(Icons.edit, color: Theme.of(context).colorScheme.onPrimary, size: 20), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 32), + _buildProfileSection(context, "Personal Information", [ + _buildProfileDetail(context, "Username:", profileData['username']), + _buildProfileDetail(context, "Email:", profileData['email']), + _buildProfileDetail(context, "First Name:", profileData['first_name']), + _buildProfileDetail(context, "Last Name:", profileData['last_name']), + _buildProfileDetail(context, "Phone Number:", profileData['phone_number']), + ]), + const SizedBox(height: 24), + _buildProfileSection(context, "Organizational Details", [ + _buildProfileDetail(context, "Role:", profileData['role_name']), + _buildProfileDetail(context, "Department:", profileData['department_name']), + _buildProfileDetail(context, "Company:", profileData['company_name']), + _buildProfileDetail(context, "Position:", profileData['position_name']), + ]), + const SizedBox(height: 24), + _buildProfileSection(context, "Account Status", [ + _buildProfileDetail(context, "Account Status:", profileData['account_status']), + _buildProfileDetail(context, "Registered On:", profileData['date_registered']), + ]), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(24.0), + child: Center( + child: ElevatedButton.icon( + icon: const Icon(Icons.logout), + label: const Text("Logout"), + onPressed: () { + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: const Text("Confirm Logout"), + content: const Text("Are you sure you want to log out?"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(dialogContext); + auth.logout(); + Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[700], + foregroundColor: Colors.white, + ), + child: const Text("Logout"), + ), + ], + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[700], + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildProfileSection(BuildContext context, String title, List details) { + return Card( + elevation: 4, + margin: const EdgeInsets.symmetric(vertical: 8.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: Theme.of(context).primaryColor), + ), + const Divider(height: 20, thickness: 1.5), + ...details, + ], + ), + ), + ); + } + + Widget _buildProfileDetail(BuildContext context, String label, dynamic value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text( + label, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 3, + child: Text( + value?.toString() ?? 'N/A', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/register.dart b/lib/screens/register.dart new file mode 100644 index 0000000..22151c5 --- /dev/null +++ b/lib/screens/register.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; + +import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; + +class RegisterScreen extends StatefulWidget { + const RegisterScreen({super.key}); + + @override + State createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + final _formKey = GlobalKey(); + final ApiService _apiService = ApiService(); + bool _isLoading = false; + String _errorMessage = ''; + + // Controllers for text fields + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _firstNameController = TextEditingController(); + final TextEditingController _lastNameController = TextEditingController(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = TextEditingController(); + final TextEditingController _phoneController = TextEditingController(); + + // State for dropdown selections + int? _selectedDepartmentId; + int? _selectedCompanyId; + int? _selectedPositionId; + + @override + void dispose() { + _usernameController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + Future _register() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + final connectivityResult = await Connectivity().checkConnectivity(); + if (connectivityResult == ConnectivityResult.none) { + if (!mounted) return; + setState(() { + _isLoading = false; + _errorMessage = 'An internet connection is required to register.'; + }); + _showSnackBar(_errorMessage, isError: true); + return; + } + + final result = await _apiService.register( + username: _usernameController.text.trim(), + firstName: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), + email: _emailController.text.trim(), + password: _passwordController.text.trim(), + phoneNumber: _phoneController.text.trim(), + departmentId: _selectedDepartmentId, + companyId: _selectedCompanyId, + positionId: _selectedPositionId, + ); + + if (!mounted) return; + + if (result['success'] == true) { + _showSnackBar('Registration successful! Please log in.', isError: false); + Navigator.of(context).pop(); + } else { + setState(() { + _isLoading = false; + _errorMessage = result['message'] ?? 'An unknown registration error occurred.'; + }); + _showSnackBar(_errorMessage, isError: true); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Theme.of(context).colorScheme.error : Colors.green, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Register')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + // Use a Consumer to rebuild the form when the provider's data changes + child: Consumer( + builder: (context, auth, child) { + // Access the cached data from the listening AuthProvider + final departments = auth.departments ?? []; + final companies = auth.companies ?? []; + final positions = auth.positions ?? []; + + return Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: _usernameController, + decoration: const InputDecoration(labelText: 'Username *'), + validator: (value) => value!.isEmpty ? 'Username is required' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _firstNameController, + decoration: const InputDecoration(labelText: 'First Name'), + ), + const SizedBox(height: 16), + TextFormField( + controller: _lastNameController, + decoration: const InputDecoration(labelText: 'Last Name'), + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email *'), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) return 'Email is required'; + if (!RegExp(r'\S+@\S+\.\S+').hasMatch(value)) return 'Please enter a valid email'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration(labelText: 'Password *'), + obscureText: true, + validator: (value) => (value?.length ?? 0) < 6 ? 'Password must be at least 6 characters' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmPasswordController, + decoration: const InputDecoration(labelText: 'Confirm Password *'), + obscureText: true, + validator: (value) { + if (value != _passwordController.text) return 'Passwords do not match'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _phoneController, + decoration: const InputDecoration(labelText: 'Phone Number'), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 16), + DropdownSearch>( + items: departments, + itemAsString: (item) => item['department_name'], + onChanged: (value) => setState(() => _selectedDepartmentId = value?['department_id']), + popupProps: const PopupProps.menu(showSearchBox: true), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Department")), + ), + const SizedBox(height: 16), + DropdownSearch>( + items: companies, + itemAsString: (item) => item['company_name'], + onChanged: (value) => setState(() => _selectedCompanyId = value?['company_id']), + popupProps: const PopupProps.menu(showSearchBox: true), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Company")), + ), + const SizedBox(height: 16), + DropdownSearch>( + items: positions, + itemAsString: (item) => item['position_name'], + onChanged: (value) => setState(() => _selectedPositionId = value?['position_id']), + popupProps: const PopupProps.menu(showSearchBox: true), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Position")), + ), + const SizedBox(height: 24), + _isLoading + ? const CircularProgressIndicator() + : ElevatedButton( + onPressed: _register, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + ), + child: const Text('Register'), + ), + if (_errorMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _errorMessage, + style: TextStyle(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/river/continuous/entry.dart b/lib/screens/river/continuous/entry.dart new file mode 100644 index 0000000..c724b5f --- /dev/null +++ b/lib/screens/river/continuous/entry.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class EntryScreen extends StatefulWidget { + @override + State createState() => _EntryScreenState(); +} + +class _EntryScreenState extends State { + final _formKey = GlobalKey(); + String station = ''; + String parameter = ''; + String value = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Continuous Entry")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Enter Monitoring Data", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + TextFormField( + decoration: InputDecoration(labelText: "Station"), + onChanged: (val) => station = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Parameter"), + onChanged: (val) => parameter = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Value"), + keyboardType: TextInputType.number, + onChanged: (val) => value = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Data submitted")), + ); + } + }, + child: Text("Submit"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/continuous/overview.dart b/lib/screens/river/continuous/overview.dart new file mode 100644 index 0000000..8a7af10 --- /dev/null +++ b/lib/screens/river/continuous/overview.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class OverviewScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Continuous Overview")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Text( + "This screen provides an overview of continuous river monitoring data, including flow rates, water quality, and station status.", + style: TextStyle(fontSize: 16), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/continuous/report.dart b/lib/screens/river/continuous/report.dart new file mode 100644 index 0000000..89fb0b1 --- /dev/null +++ b/lib/screens/river/continuous/report.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class ReportScreen extends StatelessWidget { + final List> sampleData = [ + {"Station": "River A", "Parameter": "DO", "Value": "6.5"}, + {"Station": "River B", "Parameter": "pH", "Value": "7.2"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Continuous Report")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Monitoring Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 16), + DataTable( + columns: [ + DataColumn(label: Text("Station")), + DataColumn(label: Text("Parameter")), + DataColumn(label: Text("Value")), + ], + rows: sampleData.map((data) { + return DataRow(cells: [ + DataCell(Text(data["Station"]!)), + DataCell(Text(data["Parameter"]!)), + DataCell(Text(data["Value"]!)), + ]); + }).toList(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/continuous/river_continuous_dashboard.dart b/lib/screens/river/continuous/river_continuous_dashboard.dart new file mode 100644 index 0000000..c1f787d --- /dev/null +++ b/lib/screens/river/continuous/river_continuous_dashboard.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class RiverContinuousDashboard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Continuous Monitoring")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Continuous Monitoring", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildNavButton(context, "Overview", Icons.info, '/river/continuous/overview'), + _buildNavButton(context, "Entry", Icons.edit, '/river/continuous/entry'), + _buildNavButton(context, "Report", Icons.insert_chart, '/river/continuous/report'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/investigative/entry.dart b/lib/screens/river/investigative/entry.dart new file mode 100644 index 0000000..7f24a36 --- /dev/null +++ b/lib/screens/river/investigative/entry.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class EntryScreen extends StatefulWidget { + @override + State createState() => _EntryScreenState(); +} + +class _EntryScreenState extends State { + final _formKey = GlobalKey(); + String site = ''; + String parameter = ''; + String value = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Investigative Entry")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Enter River Study Data", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + TextFormField( + decoration: InputDecoration(labelText: "Site"), + onChanged: (val) => site = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Parameter"), + onChanged: (val) => parameter = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Value"), + keyboardType: TextInputType.number, + onChanged: (val) => value = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("River data submitted")), + ); + } + }, + child: Text("Submit"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/investigative/overview.dart b/lib/screens/river/investigative/overview.dart new file mode 100644 index 0000000..cd013f5 --- /dev/null +++ b/lib/screens/river/investigative/overview.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class OverviewScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Investigative Overview")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Text( + "This screen provides an overview of investigative river studies. These are initiated to examine pollution sources, illegal discharges, or anomalies in water quality. Parameters may include DO, BOD, COD, and heavy metals.", + style: TextStyle(fontSize: 16), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/investigative/report.dart b/lib/screens/river/investigative/report.dart new file mode 100644 index 0000000..ca0ecbc --- /dev/null +++ b/lib/screens/river/investigative/report.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class ReportScreen extends StatelessWidget { + final List> sampleData = [ + {"Site": "River X", "Parameter": "BOD", "Value": "3.2 mg/L"}, + {"Site": "River Y", "Parameter": "COD", "Value": "12.5 mg/L"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Investigative Report")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Investigative Study Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 16), + DataTable( + columns: [ + DataColumn(label: Text("Site")), + DataColumn(label: Text("Parameter")), + DataColumn(label: Text("Value")), + ], + rows: sampleData.map((data) { + return DataRow(cells: [ + DataCell(Text(data["Site"]!)), + DataCell(Text(data["Parameter"]!)), + DataCell(Text(data["Value"]!)), + ]); + }).toList(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/investigative/river_investigative_dashboard.dart b/lib/screens/river/investigative/river_investigative_dashboard.dart new file mode 100644 index 0000000..35a6157 --- /dev/null +++ b/lib/screens/river/investigative/river_investigative_dashboard.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class RiverInvestigativeDashboard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Investigative Study")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Investigative Study", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildNavButton(context, "Overview", Icons.info, '/river/investigative/overview'), + _buildNavButton(context, "Entry", Icons.edit, '/river/investigative/entry'), + _buildNavButton(context, "Report", Icons.insert_chart, '/river/investigative/report'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/data_status_log.dart b/lib/screens/river/manual/data_status_log.dart new file mode 100644 index 0000000..0c691f8 --- /dev/null +++ b/lib/screens/river/manual/data_status_log.dart @@ -0,0 +1,351 @@ +// lib/screens/river/manual/widgets/data_status_log.dart + +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +// CHANGED: Import River-specific models and services +import '../../../../models/river_in_situ_sampling_data.dart'; +import '../../../../services/local_storage_service.dart'; +import '../../../../services/river_api_service.dart'; + +// A unified model to represent any type of submission log entry. +class SubmissionLogEntry { + final String type; // e.g., 'in-situ' + final String title; + final String stationCode; + final DateTime submissionDateTime; + final String? reportId; + final String status; + final String message; + final Map rawData; + bool isResubmitting; + + SubmissionLogEntry({ + required this.type, + required this.title, + required this.stationCode, + required this.submissionDateTime, + this.reportId, + required this.status, + required this.message, + required this.rawData, + this.isResubmitting = false, + }); +} + +// CHANGED: Renamed widget for River context +class RiverDataStatusLog extends StatefulWidget { + const RiverDataStatusLog({super.key}); + + @override + // CHANGED: Renamed state class + State createState() => _RiverDataStatusLogState(); +} + +// CHANGED: Renamed state class +class _RiverDataStatusLogState extends State { + final LocalStorageService _localStorageService = LocalStorageService(); + // CHANGED: Use RiverApiService + final RiverApiService _riverApiService = RiverApiService(); + + Map> _groupedLogs = {}; + Map> _filteredLogs = {}; + final Map _isCategoryExpanded = {}; + + bool _isLoading = true; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadAllLogs(); + _searchController.addListener(_filterLogs); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + /// Loads logs for the river in-situ module. + Future _loadAllLogs() async { + setState(() => _isLoading = true); + + // NOTE: Assumes a method exists in your local storage service for river logs. + final inSituLogs = await _localStorageService.getAllRiverInSituLogs(); + + final Map> tempGroupedLogs = {}; + + // REMOVED: The entire block for fetching and mapping Tarball logs was here. + + // Map In-Situ logs for River + final List inSituEntries = []; + for (var log in inSituLogs) { + final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? ''; + final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? ''; + + inSituEntries.add(SubmissionLogEntry( + type: 'In-Situ Sampling', + // CHANGED: Use river-specific station keys + title: log['selectedStation']?['r_man_station_name'] ?? 'Unknown Station', + stationCode: log['selectedStation']?['r_man_station_code'] ?? 'N/A', + submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(), + reportId: log['reportId']?.toString(), + status: log['submissionStatus'] ?? 'L1', + message: log['submissionMessage'] ?? 'No status message.', + rawData: log, + )); + } + if (inSituEntries.isNotEmpty) { + inSituEntries.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempGroupedLogs['In-Situ Sampling'] = inSituEntries; + } + + if (mounted) { + setState(() { + _groupedLogs = tempGroupedLogs; + _filteredLogs = tempGroupedLogs; + _isLoading = false; + }); + } + } + + void _filterLogs() { + final query = _searchController.text.toLowerCase(); + final Map> tempFiltered = {}; + + _groupedLogs.forEach((category, logs) { + final filtered = logs.where((log) { + return log.title.toLowerCase().contains(query) || + log.stationCode.toLowerCase().contains(query) || + (log.reportId?.toLowerCase() ?? '').contains(query); + }).toList(); + + if (filtered.isNotEmpty) { + tempFiltered[category] = filtered; + } + }); + + setState(() { + _filteredLogs = tempFiltered; + }); + } + + /// Main router for resubmitting data based on its type. + Future _resubmitData(SubmissionLogEntry log) async { + setState(() => log.isResubmitting = true); + + switch (log.type) { + // REMOVED: The case for 'Tarball Sampling' was here. + case 'In-Situ Sampling': + await _resubmitInSituData(log); + break; + default: + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Resubmission for '${log.type}' is not implemented."), backgroundColor: Colors.orange), + ); + setState(() => log.isResubmitting = false); + } + } + } + + // REMOVED: The entire _resubmitTarballData method was here. + + /// Handles resubmission for River In-Situ data. + Future _resubmitInSituData(SubmissionLogEntry log) async { + final logData = log.rawData; + + // CHANGED: Reconstruct the RiverInSituSamplingData object + final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData() + ..firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '') + ..secondSampler = logData['secondSampler'] + ..samplingDate = logData['sampling_date'] + ..samplingTime = logData['sampling_time'] + ..samplingType = logData['sampling_type'] + ..sampleIdCode = logData['sample_id_code'] + ..selectedStation = logData['selectedStation'] + ..currentLatitude = logData['current_latitude']?.toString() + ..currentLongitude = logData['current_longitude']?.toString() + ..distanceDifferenceInKm = double.tryParse(logData['distance_difference']?.toString() ?? '0.0') + ..weather = logData['weather'] + // CHANGED: Use river-specific fields + ..waterLevel = logData['water_level'] + ..riverCondition = logData['river_condition'] + ..eventRemarks = logData['event_remarks'] + ..labRemarks = logData['lab_remarks'] + ..optionalRemark1 = logData['optional_photo_remark_1'] + ..optionalRemark2 = logData['optional_photo_remark_2'] + ..optionalRemark3 = logData['optional_photo_remark_3'] + ..optionalRemark4 = logData['optional_photo_remark_4'] + ..sondeId = logData['sonde_id'] + ..dataCaptureDate = logData['data_capture_date'] + ..dataCaptureTime = logData['data_capture_time'] + ..oxygenConcentration = double.tryParse(logData['oxygen_concentration_mg_l']?.toString() ?? '0.0') + ..oxygenSaturation = double.tryParse(logData['oxygen_saturation_percent']?.toString() ?? '0.0') + ..ph = double.tryParse(logData['ph']?.toString() ?? '0.0') + ..salinity = double.tryParse(logData['salinity_ppt']?.toString() ?? '0.0') + ..electricalConductivity = double.tryParse(logData['ec_us_cm']?.toString() ?? '0.0') + ..temperature = double.tryParse(logData['temperature_c']?.toString() ?? '0.0') + ..tds = double.tryParse(logData['tds_mg_l']?.toString() ?? '0.0') + ..turbidity = double.tryParse(logData['turbidity_ntu']?.toString() ?? '0.0') + ..tss = double.tryParse(logData['tss_mg_l']?.toString() ?? '0.0') + ..batteryVoltage = double.tryParse(logData['battery_v']?.toString() ?? '0.0'); + + final Map imageFiles = {}; + final imageKeys = dataToResubmit.toApiImageFiles().keys; + for (var key in imageKeys) { + final imagePath = logData[key]; + if (imagePath is String && imagePath.isNotEmpty) { + final file = File(imagePath); + if (await file.exists()) { + imageFiles[key] = file; + } + } + } + + // CHANGED: Submit the data via the RiverApiService + final result = await _riverApiService.submitInSituSample( + formData: dataToResubmit.toApiFormData(), + imageFiles: imageFiles, + ); + + logData['submissionStatus'] = result['status']; + logData['submissionMessage'] = result['message']; + logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; + + // NOTE: Assumes a method exists to update river logs. + await _localStorageService.updateRiverInSituLog(logData); + + if (mounted) await _loadAllLogs(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + // CHANGED: Updated AppBar title + appBar: AppBar(title: const Text('River Data Status Log')), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: 'Search Logs...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12.0)), + ), + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _loadAllLogs, + child: _filteredLogs.isEmpty + ? Center(child: Text(_groupedLogs.isEmpty ? 'No submission logs found.' : 'No logs match your search.')) + : ListView( + children: _filteredLogs.entries.map((entry) { + return _buildCategorySection(entry.key, entry.value); + }).toList(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCategorySection(String category, List logs) { + final bool isExpanded = _isCategoryExpanded[category] ?? false; + final int itemCount = isExpanded ? logs.length : (logs.length > 5 ? 5 : logs.length); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + const Divider(), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: itemCount, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, + ), + if (logs.length > 5) + TextButton( + onPressed: () { + setState(() { + _isCategoryExpanded[category] = !isExpanded; + }); + }, + child: Text(isExpanded ? 'Show Less' : 'Show More (${logs.length - 5} more)'), + ), + ], + ), + ), + ); + } + + Widget _buildLogListItem(SubmissionLogEntry log) { + final isFailed = log.status != 'L3'; + final title = '${log.title} (${log.stationCode})'; + final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime); + + return ExpansionTile( + leading: Icon( + isFailed ? Icons.error_outline : Icons.check_circle_outline, + color: isFailed ? Colors.red : Colors.green, + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(subtitle), + trailing: isFailed + ? (log.isResubmitting + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 3), + ) + : IconButton( + icon: const Icon(Icons.sync, color: Colors.blue), + tooltip: 'Resubmit', + onPressed: () => _resubmitData(log), + )) + : null, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), + _buildDetailRow('Status:', log.message), + _buildDetailRow('Submission Type:', log.type), + ], + ), + ) + ], + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)), + Expanded(child: Text(value)), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/image_request.dart b/lib/screens/river/manual/image_request.dart new file mode 100644 index 0000000..7cacac0 --- /dev/null +++ b/lib/screens/river/manual/image_request.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; // Add this line at the top of these files + + +class RiverManualImageRequest extends StatefulWidget { + @override + State createState() => _RiverManualImageRequestState(); +} + +class _RiverManualImageRequestState extends State { + XFile? _image; + final picker = ImagePicker(); + final _descriptionController = TextEditingController(); + + Future _pickImage() async { + final pickedFile = await picker.pickImage(source: ImageSource.camera); + setState(() => _image = pickedFile); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Manual Image Request")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + ElevatedButton.icon( + icon: Icon(Icons.camera_alt), + label: Text("Capture Image"), + onPressed: _pickImage, + ), + SizedBox(height: 16), + if (_image != null) + Image.file( + File(_image!.path), + height: 200, + ), + SizedBox(height: 16), + TextField( + controller: _descriptionController, + decoration: InputDecoration(labelText: "Description"), + maxLines: 3, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + // Submit logic here + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Image request submitted")), + ); + }, + child: Text("Submit Request"), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/in_situ_sampling.dart b/lib/screens/river/manual/in_situ_sampling.dart new file mode 100644 index 0000000..792cb7c --- /dev/null +++ b/lib/screens/river/manual/in_situ_sampling.dart @@ -0,0 +1,142 @@ +// lib/screens/river/manual/in_situ_sampling.dart + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../../../models/river_in_situ_sampling_data.dart'; +import '../../../services/river_in_situ_sampling_service.dart'; +import '../../../services/local_storage_service.dart'; +import 'widgets/river_in_situ_step_1_sampling_info.dart'; +import 'widgets/river_in_situ_step_2_site_info.dart'; +import 'widgets/river_in_situ_step_3_data_capture.dart'; +import 'widgets/river_in_situ_step_4_summary.dart'; + +/// The main screen for the River In-Situ Sampling feature. +/// This stateful widget orchestrates the multi-step process using a PageView. +/// It manages the overall data model and the service layer for the entire workflow. +class RiverInSituSamplingScreen extends StatefulWidget { + const RiverInSituSamplingScreen({super.key}); + + @override + State createState() => _RiverInSituSamplingScreenState(); +} + +class _RiverInSituSamplingScreenState extends State { + final PageController _pageController = PageController(); + + late RiverInSituSamplingData _data; + + // A single instance of the service to be used by all child widgets. + final RiverInSituSamplingService _samplingService = RiverInSituSamplingService(); + + // Service for saving submission logs locally. + final LocalStorageService _localStorageService = LocalStorageService(); + + int _currentPage = 0; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + // Creates a NEW data object with the CURRENT date and time + // every time the user starts a new sampling. + _data = RiverInSituSamplingData( + samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), + samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()), + ); + } + + @override + void dispose() { + _pageController.dispose(); + _samplingService.dispose(); + super.dispose(); + } + + /// Navigates to the next page in the form. + void _nextPage() { + if (_currentPage < 3) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + /// Navigates to the previous page in the form. + void _previousPage() { + if (_currentPage > 0) { + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + /// Handles the final submission process. + Future _submitForm() async { + setState(() => _isLoading = true); + + final result = await _samplingService.submitData(_data); + + if (!mounted) return; + + _data.submissionStatus = result['status']; + _data.submissionMessage = result['message']; + _data.reportId = result['reportId']?.toString(); + + // Save a log of the submission locally using the river-specific method. + await _localStorageService.saveRiverInSituSamplingData(_data); + + setState(() => _isLoading = false); + + final message = result['message'] ?? 'An unknown error occurred.'; + final color = (result['status'] == 'L3') + ? Colors.green + : (result['status'] == 'L2' ? Colors.orange : Colors.red); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), + ); + + if (result['status'] == 'L3') { + Navigator.of(context).popUntil((route) => route.isFirst); + } + } + + @override + Widget build(BuildContext context) { + // Use Provider.value to provide the existing river service instance to all child widgets. + return Provider.value( + value: _samplingService, + child: Scaffold( + appBar: AppBar( + title: Text('In-Situ Sampling (${_currentPage + 1}/4)'), + leading: _currentPage > 0 + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _previousPage, + ) + : null, + ), + body: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + children: [ + // Each step is a separate river-specific widget. + RiverInSituStep1SamplingInfo(data: _data, onNext: _nextPage), + RiverInSituStep2SiteInfo(data: _data, onNext: _nextPage), + RiverInSituStep3DataCapture(data: _data, onNext: _nextPage), + RiverInSituStep4Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/report.dart b/lib/screens/river/manual/report.dart new file mode 100644 index 0000000..210bd06 --- /dev/null +++ b/lib/screens/river/manual/report.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class RiverManualReport extends StatelessWidget { + final List> sampleData = [ + {"Station": "River Site A", "Parameter": "DO", "Value": "6.8"}, + {"Station": "River Site B", "Parameter": "pH", "Value": "7.1"}, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Manual Report")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Manual Sampling Report", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 16), + DataTable( + columns: [ + DataColumn(label: Text("Station")), + DataColumn(label: Text("Parameter")), + DataColumn(label: Text("Value")), + ], + rows: sampleData.map((data) { + return DataRow(cells: [ + DataCell(Text(data["Station"]!)), + DataCell(Text(data["Parameter"]!)), + DataCell(Text(data["Value"]!)), + ]); + }).toList(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/river_manual_dashboard.dart b/lib/screens/river/manual/river_manual_dashboard.dart new file mode 100644 index 0000000..5ad1d98 --- /dev/null +++ b/lib/screens/river/manual/river_manual_dashboard.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class RiverManualDashboard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Manual Sampling")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Manual Sampling", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildNavButton(context, "Overview", Icons.info, '/river/manual/overview'), + _buildNavButton(context, "Entry", Icons.edit, '/river/manual/entry'), + _buildNavButton(context, "Report", Icons.insert_chart, '/river/manual/report'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { + return ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, route), + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/triennial_sampling.dart b/lib/screens/river/manual/triennial_sampling.dart new file mode 100644 index 0000000..c492573 --- /dev/null +++ b/lib/screens/river/manual/triennial_sampling.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +class RiverTriennialSampling extends StatefulWidget { + @override + State createState() => _RiverTriennialSamplingState(); +} + +class _RiverTriennialSamplingState extends State { + final _formKey = GlobalKey(); + String site = ''; + String parameter = ''; + String value = ''; + String year = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("River Triennial Sampling")), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: ListView( + children: [ + Text("Enter Triennial Sampling Data", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 24), + TextFormField( + decoration: InputDecoration(labelText: "Site"), + onChanged: (val) => site = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Parameter"), + onChanged: (val) => parameter = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Value"), + keyboardType: TextInputType.number, + onChanged: (val) => value = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 16), + TextFormField( + decoration: InputDecoration(labelText: "Year"), + keyboardType: TextInputType.number, + onChanged: (val) => year = val, + validator: (val) => val == null || val.isEmpty ? "Required" : null, + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Triennial sampling data submitted")), + ); + } + }, + child: Text("Submit"), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart b/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart new file mode 100644 index 0000000..62a18bb --- /dev/null +++ b/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart @@ -0,0 +1,440 @@ +// lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; +import 'package:intl/intl.dart'; +import 'package:simple_barcode_scanner/simple_barcode_scanner.dart'; + +import '../../../../auth_provider.dart'; +import '../../../../models/river_in_situ_sampling_data.dart'; +import '../../../../services/river_in_situ_sampling_service.dart'; + +class RiverInSituStep1SamplingInfo extends StatefulWidget { + final RiverInSituSamplingData data; + final VoidCallback onNext; + + const RiverInSituStep1SamplingInfo({ + super.key, + required this.data, + required this.onNext, + }); + + @override + State createState() => _RiverInSituStep1SamplingInfoState(); +} + +class _RiverInSituStep1SamplingInfoState extends State { + final _formKey = GlobalKey(); + bool _isLoadingLocation = false; + + late final TextEditingController _firstSamplerController; + late final TextEditingController _dateController; + late final TextEditingController _timeController; + late final TextEditingController _barcodeController; + late final TextEditingController _stationLatController; + late final TextEditingController _stationLonController; + late final TextEditingController _currentLatController; + late final TextEditingController _currentLonController; + + List _statesList = []; + List _categoriesForState = []; + List> _stationsForCategory = []; + final List _samplingTypes = ['Schedule', 'Ad-Hoc', 'Complaint']; + + @override + void initState() { + super.initState(); + _initializeControllers(); + _initializeForm(); + } + + @override + void dispose() { + _firstSamplerController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + _barcodeController.dispose(); + _stationLatController.dispose(); + _stationLonController.dispose(); + _currentLatController.dispose(); + _currentLonController.dispose(); + super.dispose(); + } + + void _initializeControllers() { + _firstSamplerController = TextEditingController(); + _dateController = TextEditingController(); + _timeController = TextEditingController(); + _barcodeController = TextEditingController(text: widget.data.sampleIdCode); + _stationLatController = TextEditingController(text: widget.data.stationLatitude); + _stationLonController = TextEditingController(text: widget.data.stationLongitude); + _currentLatController = TextEditingController(text: widget.data.currentLatitude); + _currentLonController = TextEditingController(text: widget.data.currentLongitude); + } + + void _initializeForm() { + final auth = Provider.of(context, listen: false); + + widget.data.firstSamplerName = auth.profileData?['first_name'] ?? 'Current User'; + widget.data.firstSamplerUserId = auth.profileData?['user_id']; + _firstSamplerController.text = widget.data.firstSamplerName!; + + final now = DateTime.now(); + if (widget.data.samplingDate == null || widget.data.samplingDate!.isEmpty) { + widget.data.samplingDate = DateFormat('yyyy-MM-dd').format(now); + widget.data.samplingTime = DateFormat('HH:mm:ss').format(now); + } + _dateController.text = widget.data.samplingDate!; + _timeController.text = widget.data.samplingTime!; + + final allStations = auth.riverManualStations ?? []; + if (allStations.isNotEmpty) { + final states = allStations.map((s) => s['state_name'] as String?).whereType().toSet().toList(); + states.sort(); + + if (widget.data.selectedStateName != null) { + final categories = allStations + .where((s) => s['state_name'] == widget.data.selectedStateName) + .map((s) => s['category_name'] as String?) + .whereType() + .toSet() + .toList(); + categories.sort(); + _categoriesForState = categories; + } + if (widget.data.selectedCategoryName != null) { + _stationsForCategory = allStations + .where((s) => + s['state_name'] == widget.data.selectedStateName && + s['category_name'] == widget.data.selectedCategoryName) + .toList(); + } + + setState(() { + _statesList = states; + }); + } + } + + Future _getCurrentLocation() async { + setState(() => _isLoadingLocation = true); + final service = Provider.of(context, listen: false); + + try { + final position = await service.getCurrentLocation(); + if (mounted) { + setState(() { + widget.data.currentLatitude = position.latitude.toString(); + widget.data.currentLongitude = position.longitude.toString(); + _currentLatController.text = widget.data.currentLatitude!; + _currentLonController.text = widget.data.currentLongitude!; + _calculateDistance(); + }); + } + } catch (e) { + if(mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e'))); + } + } finally { + if (mounted) { + setState(() => _isLoadingLocation = false); + } + } + } + + void _calculateDistance() { + final lat1Str = widget.data.stationLatitude; + final lon1Str = widget.data.stationLongitude; + final lat2Str = widget.data.currentLatitude; + final lon2Str = widget.data.currentLongitude; + + if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) { + final service = Provider.of(context, listen: false); + final lat1 = double.tryParse(lat1Str); + final lon1 = double.tryParse(lon1Str); + final lat2 = double.tryParse(lat2Str); + final lon2 = double.tryParse(lon2Str); + + if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) { + final distance = service.calculateDistance(lat1, lon1, lat2, lon2); + setState(() { + widget.data.distanceDifferenceInKm = distance; + }); + } + } + } + + Future _scanBarcode() async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()), + ); + if (result is String && result != '-1' && mounted) { + setState(() { + widget.data.sampleIdCode = result; + _barcodeController.text = result; + }); + } + } + + void _goToNextStep() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + + final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000; + + if (distanceInMeters > 700) { + _showDistanceRemarkDialog(); + } else { + widget.data.distanceDifferenceRemarks = null; + widget.onNext(); + } + } + } + + Future _showDistanceRemarkDialog() async { + final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); + final dialogFormKey = GlobalKey(); + + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Distance Warning'), + content: SingleChildScrollView( + child: Form( + key: dialogFormKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Your current location is more than 700m away from the station.'), + const SizedBox(height: 16), + TextFormField( + controller: remarkController, + decoration: const InputDecoration( + labelText: 'Remarks *', + hintText: 'Please provide a reason...', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Remarks are required to continue.'; + } + return null; + }, + maxLines: 3, + ), + ], + ), + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + FilledButton( + child: const Text('Confirm'), + onPressed: () { + if (dialogFormKey.currentState!.validate()) { + setState(() { + widget.data.distanceDifferenceRemarks = remarkController.text; + }); + Navigator.of(context).pop(); + widget.onNext(); + } + }, + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final auth = Provider.of(context, listen: false); + final allStations = auth.riverManualStations ?? []; + final allUsers = auth.allUsers ?? []; + final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList(); + + return Form( + key: _formKey, + child: ListView( + // REMOVED: physics property to allow scrolling. + // shrinkWrap: true, // This can be kept or removed; it's less critical in PageView. + padding: const EdgeInsets.all(24.0), + children: [ + Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')), + const SizedBox(height: 16), + DropdownSearch>( + items: secondSamplersList, + selectedItem: widget.data.secondSampler, + itemAsString: (sampler) => "${sampler['first_name']} ${sampler['last_name']}", + onChanged: (sampler) => widget.data.secondSampler = sampler, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Sampler..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: '2nd Sampler (Optional)')), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))), + const SizedBox(width: 16), + Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))), + ], + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: widget.data.samplingType, + items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(), + onChanged: (value) => setState(() => widget.data.samplingType = value), + decoration: const InputDecoration(labelText: 'Sampling Type *'), + validator: (value) => value == null ? 'Please select a type' : null, + ), + const SizedBox(height: 24), + + DropdownSearch( + items: _statesList, + selectedItem: widget.data.selectedStateName, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")), + onChanged: (state) { + setState(() { + widget.data.selectedStateName = state; + widget.data.selectedCategoryName = null; + widget.data.selectedStation = null; + _stationLatController.clear(); + _stationLonController.clear(); + widget.data.distanceDifferenceInKm = null; + + final categories = state != null + ? allStations + .where((s) => s['state_name'] == state) + .map((s) => s['category_name'] as String?) + .whereType() + .toSet() + .toList() + : []; + categories.sort(); + _categoriesForState = categories; + + _stationsForCategory = []; + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 16), + DropdownSearch( + items: _categoriesForState, + selectedItem: widget.data.selectedCategoryName, + enabled: widget.data.selectedStateName != null, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *")), + onChanged: (category) { + setState(() { + widget.data.selectedCategoryName = category; + widget.data.selectedStation = null; + _stationLatController.clear(); + _stationLonController.clear(); + widget.data.distanceDifferenceInKm = null; + _stationsForCategory = category != null ? allStations.where((s) => s['state_name'] == widget.data.selectedStateName && s['category_name'] == category).toList() : []; + }); + }, + validator: (val) => widget.data.selectedStateName != null && val == null ? "Category is required" : null, + ), + const SizedBox(height: 16), + DropdownSearch>( + items: _stationsForCategory, + selectedItem: widget.data.selectedStation, + enabled: widget.data.selectedCategoryName != null, + itemAsString: (station) => "${station['r_man_station_code']} - ${station['r_man_station_name']}", + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *")), + onChanged: (station) => setState(() { + widget.data.selectedStation = station; + widget.data.stationLatitude = station?['r_man_latitude']?.toString(); + widget.data.stationLongitude = station?['r_man_longitude']?.toString(); + _stationLatController.text = widget.data.stationLatitude ?? ''; + _stationLonController.text = widget.data.stationLongitude ?? ''; + _calculateDistance(); + }), + validator: (val) => widget.data.selectedCategoryName != null && val == null ? "Station is required" : null, + ), + const SizedBox(height: 16), + TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')), + const SizedBox(height: 16), + TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')), + const SizedBox(height: 24), + + Text("Location Verification", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')), + const SizedBox(height: 16), + TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')), + if (widget.data.distanceDifferenceInKm != null) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red : Colors.green), + ), + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: Theme.of(context).textTheme.bodyLarge, + children: [ + const TextSpan(text: 'Distance from Station: '), + TextSpan( + text: '${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters', + style: TextStyle( + fontWeight: FontWeight.bold, + color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red : Colors.green + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _isLoadingLocation ? null : _getCurrentLocation, + icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching), + label: const Text("Get Current Location"), + ), + const SizedBox(height: 16), + + TextFormField( + controller: _barcodeController, + decoration: InputDecoration( + labelText: 'Sample ID Code *', + suffixIcon: IconButton( + icon: const Icon(Icons.qr_code_scanner), + onPressed: _scanBarcode, + ), + ), + validator: (val) => val == null || val.isEmpty ? "Sample ID is required" : null, + onSaved: (val) => widget.data.sampleIdCode = val, + onChanged: (val) => widget.data.sampleIdCode = val, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _goToNextStep, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('Next'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_2_site_info.dart b/lib/screens/river/manual/widgets/river_in_situ_step_2_site_info.dart new file mode 100644 index 0000000..07595b9 --- /dev/null +++ b/lib/screens/river/manual/widgets/river_in_situ_step_2_site_info.dart @@ -0,0 +1,238 @@ +// lib/screens/river/manual/widgets/river_in_situ_step_2_site_info.dart + +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; + +import '../../../../models/river_in_situ_sampling_data.dart'; +import '../../../../services/river_in_situ_sampling_service.dart'; + +class RiverInSituStep2SiteInfo extends StatefulWidget { + final RiverInSituSamplingData data; + final VoidCallback onNext; + + const RiverInSituStep2SiteInfo({ + super.key, + required this.data, + required this.onNext, + }); + + @override + State createState() => _RiverInSituStep2SiteInfoState(); +} + +class _RiverInSituStep2SiteInfoState extends State { + final _formKey = GlobalKey(); + bool _isPickingImage = false; + + late final TextEditingController _eventRemarksController; + late final TextEditingController _labRemarksController; + late final TextEditingController _optionalRemark1Controller; + late final TextEditingController _optionalRemark2Controller; + late final TextEditingController _optionalRemark3Controller; + late final TextEditingController _optionalRemark4Controller; + + final List _weatherOptions = ['Clear', 'Rainy', 'Cloudy']; + final List _waterLevelOptions = ['Normal', 'High', 'Low']; + final List _riverConditionOptions = ['Calm', 'Moderate Flow', 'Fast Flow']; + + @override + void initState() { + super.initState(); + _eventRemarksController = TextEditingController(text: widget.data.eventRemarks); + _labRemarksController = TextEditingController(text: widget.data.labRemarks); + _optionalRemark1Controller = TextEditingController(text: widget.data.optionalRemark1); + _optionalRemark2Controller = TextEditingController(text: widget.data.optionalRemark2); + _optionalRemark3Controller = TextEditingController(text: widget.data.optionalRemark3); + _optionalRemark4Controller = TextEditingController(text: widget.data.optionalRemark4); + } + + @override + void dispose() { + _eventRemarksController.dispose(); + _labRemarksController.dispose(); + _optionalRemark1Controller.dispose(); + _optionalRemark2Controller.dispose(); + _optionalRemark3Controller.dispose(); + _optionalRemark4Controller.dispose(); + super.dispose(); + } + + void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async { + if (_isPickingImage) return; + setState(() => _isPickingImage = true); + + final service = Provider.of(context, listen: false); + final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired); + + if (file != null) { + setState(() => setImageCallback(file)); + } else if (mounted) { + _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true); + } + + if (mounted) { + setState(() => _isPickingImage = false); + } + } + + void _goToNextStep() { + if (!_formKey.currentState!.validate()) { + return; + } + + if (widget.data.leftBankViewImage == null || + widget.data.rightBankViewImage == null || + widget.data.waterFillingImage == null || + widget.data.waterColorImage == null || + widget.data.phPaperImage == null) { + _showSnackBar('Please attach all 5 required photos before proceeding.', isError: true); + return; + } + + _formKey.currentState!.save(); + widget.data.optionalRemark1 = _optionalRemark1Controller.text; + widget.data.optionalRemark2 = _optionalRemark2Controller.text; + widget.data.optionalRemark3 = _optionalRemark3Controller.text; + widget.data.optionalRemark4 = _optionalRemark4Controller.text; + widget.onNext(); + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : null, + )); + } + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: ListView( + // REMOVED: physics property to allow scrolling. + padding: const EdgeInsets.all(24.0), + children: [ + Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + DropdownButtonFormField( + value: widget.data.weather, + items: _weatherOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(), + onChanged: (value) => setState(() => widget.data.weather = value), + decoration: const InputDecoration(labelText: 'Weather *'), + validator: (value) => value == null ? 'Weather is required' : null, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: widget.data.waterLevel, + items: _waterLevelOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(), + onChanged: (value) => setState(() => widget.data.waterLevel = value), + decoration: const InputDecoration(labelText: 'Water Level *'), + validator: (value) => value == null ? 'Water level is required' : null, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: widget.data.riverCondition, + items: _riverConditionOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(), + onChanged: (value) => setState(() => widget.data.riverCondition = value), + decoration: const InputDecoration(labelText: 'River Condition *'), + validator: (value) => value == null ? 'River condition is required' : null, + ), + const SizedBox(height: 24), + + Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), + const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)), + const SizedBox(height: 8), + _buildImagePicker('Left Bank View', 'LEFT_BANK_VIEW', widget.data.leftBankViewImage, (file) => widget.data.leftBankViewImage = file, isRequired: true), + _buildImagePicker('Right Bank View', 'RIGHT_BANK_VIEW', widget.data.rightBankViewImage, (file) => widget.data.rightBankViewImage = file, isRequired: true), + _buildImagePicker('Filling Water into Sample Bottle', 'WATER_FILLING', widget.data.waterFillingImage, (file) => widget.data.waterFillingImage = file, isRequired: true), + _buildImagePicker('Water in Clear Glass Bottle', 'WATER_COLOR', widget.data.waterColorImage, (file) => widget.data.waterColorImage = file, isRequired: true), + _buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: true), + const SizedBox(height: 24), + + Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + _buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: false), + _buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: false), + _buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: false), + _buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: false), + const SizedBox(height: 24), + + Text("Remarks", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + TextFormField( + controller: _eventRemarksController, + decoration: const InputDecoration(labelText: 'Event Remarks (Optional)', hintText: 'e.g., unusual smells, colors, etc.'), + onSaved: (value) => widget.data.eventRemarks = value, + maxLines: 3, + ), + const SizedBox(height: 16), + TextFormField( + controller: _labRemarksController, + decoration: const InputDecoration(labelText: 'Lab Remarks (Optional)'), + onSaved: (value) => widget.data.labRemarks = value, + maxLines: 3, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _goToNextStep, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('Next'), + ), + ], + ), + ); + } + + Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + if (imageFile != null) + Stack( + alignment: Alignment.topRight, + children: [ + ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)), + Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle), + child: IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, color: Colors.white, size: 20), + onPressed: () => setState(() => setImageCallback(null)), + ), + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")), + ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), + ], + ), + if (remarkController != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextFormField( + controller: remarkController, + decoration: InputDecoration( + labelText: 'Remarks for $title', + hintText: 'Add an optional remark...', + border: const OutlineInputBorder(), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart new file mode 100644 index 0000000..b0598a7 --- /dev/null +++ b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart @@ -0,0 +1,494 @@ +// lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; +import 'package:usb_serial/usb_serial.dart'; + +import '../../../../models/river_in_situ_sampling_data.dart'; +import '../../../../services/river_in_situ_sampling_service.dart'; +import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum +import '../../../../serial/serial_manager.dart'; // For connection state enum +import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart'; +import '../../../../serial/widget/serial_port_list_dialog.dart'; + +class RiverInSituStep3DataCapture extends StatefulWidget { + final RiverInSituSamplingData data; + final VoidCallback onNext; + + const RiverInSituStep3DataCapture({ + super.key, + required this.data, + required this.onNext, + }); + + @override + State createState() => _RiverInSituStep3DataCaptureState(); +} + +class _RiverInSituStep3DataCaptureState extends State { + final _formKey = GlobalKey(); + bool _isLoading = false; + bool _isAutoReading = false; + StreamSubscription? _dataSubscription; + + final List> _parameters = []; + + final _sondeIdController = TextEditingController(); + final _dateController = TextEditingController(); + final _timeController = TextEditingController(); + final _oxyConcController = TextEditingController(); + final _oxySatController = TextEditingController(); + final _phController = TextEditingController(); + final _salinityController = TextEditingController(); + final _ecController = TextEditingController(); + final _tempController = TextEditingController(); + final _tdsController = TextEditingController(); + final _turbidityController = TextEditingController(); + final _tssController = TextEditingController(); + final _batteryController = TextEditingController(); + // NOTE: If you add river-specific parameters, add their controllers here. + + @override + void initState() { + super.initState(); + _initializeControllers(); + } + + @override + void dispose() { + _dataSubscription?.cancel(); + _disposeControllers(); + super.dispose(); + } + + void _initializeControllers() { + widget.data.dataCaptureDate = widget.data.samplingDate; + widget.data.dataCaptureTime = widget.data.samplingTime; + + _sondeIdController.text = widget.data.sondeId ?? ''; + _dateController.text = widget.data.dataCaptureDate ?? ''; + _timeController.text = widget.data.dataCaptureTime ?? ''; + + widget.data.oxygenConcentration ??= -999.0; + widget.data.oxygenSaturation ??= -999.0; + widget.data.ph ??= -999.0; + widget.data.salinity ??= -999.0; + widget.data.electricalConductivity ??= -999.0; + widget.data.temperature ??= -999.0; + widget.data.tds ??= -999.0; + widget.data.turbidity ??= -999.0; + widget.data.tss ??= -999.0; + widget.data.batteryVoltage ??= -999.0; + // NOTE: Initialize your river-specific parameters here (e.g., widget.data.flowRate ??= -999.0;) + + _oxyConcController.text = widget.data.oxygenConcentration!.toString(); + _oxySatController.text = widget.data.oxygenSaturation!.toString(); + _phController.text = widget.data.ph!.toString(); + _salinityController.text = widget.data.salinity!.toString(); + _ecController.text = widget.data.electricalConductivity!.toString(); + _tempController.text = widget.data.temperature!.toString(); + _tdsController.text = widget.data.tds!.toString(); + _turbidityController.text = widget.data.turbidity!.toString(); + _tssController.text = widget.data.tss!.toString(); + _batteryController.text = widget.data.batteryVoltage!.toString(); + + if (_parameters.isEmpty) { + _parameters.addAll([ + {'icon': Icons.air, 'label': 'Oxygen Conc.', 'unit': 'mg/L', 'controller': _oxyConcController}, + {'icon': Icons.percent, 'label': 'Oxygen Sat.', 'unit': '%', 'controller': _oxySatController}, + {'icon': Icons.science_outlined, 'label': 'pH', 'unit': '', 'controller': _phController}, + {'icon': Icons.waves, 'label': 'Salinity', 'unit': 'ppt', 'controller': _salinityController}, + {'icon': Icons.flash_on, 'label': 'Conductivity', 'unit': 'µS/cm', 'controller': _ecController}, + {'icon': Icons.thermostat, 'label': 'Temperature', 'unit': '°C', 'controller': _tempController}, + {'icon': Icons.grain, 'label': 'TDS', 'unit': 'mg/L', 'controller': _tdsController}, + {'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController}, + {'icon': Icons.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController}, + {'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController}, + // NOTE: If you add river-specific parameters, add them to this list. + ]); + } + } + + void _disposeControllers() { + _sondeIdController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + _oxyConcController.dispose(); + _oxySatController.dispose(); + _phController.dispose(); + _salinityController.dispose(); + _ecController.dispose(); + _tempController.dispose(); + _tdsController.dispose(); + _turbidityController.dispose(); + _tssController.dispose(); + _batteryController.dispose(); + // NOTE: Dispose your river-specific controllers here. + } + + Future _handleConnectionAttempt(String type) async { + final service = context.read(); + + final bool hasPermissions = await service.requestDevicePermissions(); + if (!hasPermissions && mounted) { + _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); + return; + } + + _disconnectFromAll(); + await Future.delayed(const Duration(milliseconds: 250)); + + final bool connectionSuccess = await _connectToDevice(type); + + if (connectionSuccess && mounted) { + _dataSubscription?.cancel(); + final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream; + + _dataSubscription = stream.listen((readings) { + if (mounted) { + _updateTextFields(readings); + } + }); + } + } + + Future _connectToDevice(String type) async { + setState(() => _isLoading = true); + final service = context.read(); + bool success = false; + + try { + if (type == 'bluetooth') { + final devices = await service.getPairedBluetoothDevices(); + if (devices.isEmpty && mounted) { + _showSnackBar('No paired Bluetooth devices found.', isError: true); + return false; + } + final selectedDevice = await showBluetoothDeviceListDialog(context: context, devices: devices); + if (selectedDevice != null) { + await service.connectToBluetoothDevice(selectedDevice); + success = true; + } + } else if (type == 'serial') { + final devices = await service.getAvailableSerialDevices(); + if (devices.isEmpty && mounted) { + _showSnackBar('No USB Serial devices found.', isError: true); + return false; + } + final selectedDevice = await showSerialPortListDialog(context: context, devices: devices); + if (selectedDevice != null) { + await service.connectToSerialDevice(selectedDevice); + success = true; + } + } + } catch (e) { + if (mounted) _showSnackBar('Connection failed: $e', isError: true); + success = false; + } finally { + if (mounted) setState(() => _isLoading = false); + } + return success; + } + + void _toggleAutoReading(String activeType) { + final service = context.read(); + setState(() { + _isAutoReading = !_isAutoReading; + if (_isAutoReading) { + if (activeType == 'bluetooth') service.startBluetoothAutoReading(); + else service.startSerialAutoReading(); + } else { + if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); + else service.stopSerialAutoReading(); + } + }); + } + + void _disconnect(String type) { + final service = context.read(); + if (type == 'bluetooth') { + service.disconnectFromBluetooth(); + } else { + service.disconnectFromSerial(); + } + _dataSubscription?.cancel(); + _dataSubscription = null; + if (mounted) { + setState(() => _isAutoReading = false); + } + } + + void _disconnectFromAll() { + final service = context.read(); + if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + _disconnect('bluetooth'); + } + if (service.serialConnectionState.value != SerialConnectionState.disconnected) { + _disconnect('serial'); + } + } + + void _updateTextFields(Map readings) { + const defaultValue = -999.0; + setState(() { + _oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5); + _oxySatController.text = (readings['Optical Dissolved Oxygen: Compensated % Saturation'] ?? defaultValue).toStringAsFixed(5); + _phController.text = (readings['PH: PH units'] ?? defaultValue).toStringAsFixed(5); + _tempController.text = (readings['External Temp: Degrees Celcius'] ?? defaultValue).toStringAsFixed(5); + _ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5); + _salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5); + _tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5); + _tssController.text = (readings['Turbidity: TSS'] ?? defaultValue).toStringAsFixed(5); + _turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5); + _batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5); + }); + } + + void _goToNextStep() { + if (_isAutoReading) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Data Collection Active'), + content: const Text('Please stop the live data collection before proceeding.'), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + return; + } + + if (_formKey.currentState!.validate()){ + _formKey.currentState!.save(); + + try { + const defaultValue = -999.0; + widget.data.temperature = double.tryParse(_tempController.text) ?? defaultValue; + widget.data.ph = double.tryParse(_phController.text) ?? defaultValue; + widget.data.salinity = double.tryParse(_salinityController.text) ?? defaultValue; + widget.data.electricalConductivity = double.tryParse(_ecController.text) ?? defaultValue; + widget.data.oxygenConcentration = double.tryParse(_oxyConcController.text) ?? defaultValue; + widget.data.oxygenSaturation = double.tryParse(_oxySatController.text) ?? defaultValue; + widget.data.tds = double.tryParse(_tdsController.text) ?? defaultValue; + widget.data.turbidity = double.tryParse(_turbidityController.text) ?? defaultValue; + widget.data.tss = double.tryParse(_tssController.text) ?? defaultValue; + widget.data.batteryVoltage = double.tryParse(_batteryController.text) ?? defaultValue; + } catch (e) { + _showSnackBar("Could not save parameters due to a data format error.", isError: true); + return; + } + + widget.onNext(); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : null, + )); + } + } + + Map? _getActiveConnectionDetails() { + final service = context.watch(); + if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName}; + } + if (service.serialConnectionState.value != SerialConnectionState.disconnected) { + return {'type': 'serial', 'state': service.serialConnectionState.value, 'name': service.connectedSerialDeviceName}; + } + return null; + } + + @override + Widget build(BuildContext context) { + final service = context.watch(); + final activeConnection = _getActiveConnectionDetails(); + final String? activeType = activeConnection?['type'] as String?; + + return Form( + key: _formKey, + child: ListView( + // CORRECTED: Scrolling is enabled by removing the physics property. + padding: const EdgeInsets.all(24.0), + children: [ + Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: activeType == 'bluetooth' + ? FilledButton.icon( + icon: const Icon(Icons.bluetooth_connected), + label: const Text("Bluetooth"), + onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'), + ) + : OutlinedButton.icon( + icon: const Icon(Icons.bluetooth), + label: const Text("Bluetooth"), + onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: activeType == 'serial' + ? FilledButton.icon( + icon: const Icon(Icons.usb), + label: const Text("USB Serial"), + onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'), + ) + : OutlinedButton.icon( + icon: const Icon(Icons.usb), + label: const Text("USB Serial"), + onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'), + ), + ), + ], + ), + const SizedBox(height: 16), + if (activeConnection != null) + _buildConnectionCard( + type: activeConnection['type'], + connectionState: activeConnection['state'], + deviceName: activeConnection['name'], + ), + const SizedBox(height: 24), + + ValueListenableBuilder( + valueListenable: service.sondeId, + builder: (context, sondeId, child) { + final newSondeId = sondeId ?? ''; + if (_sondeIdController.text != newSondeId) { + _sondeIdController.text = newSondeId; + widget.data.sondeId = newSondeId; + } + return TextFormField( + controller: _sondeIdController, + decoration: const InputDecoration( + labelText: 'Sonde ID *', + hintText: 'Connect device or enter manually'), + validator: (v) => v == null || v.isEmpty ? 'Sonde ID is required' : null, + onChanged: (value) { + widget.data.sondeId = value; + }, + onSaved: (v) => widget.data.sondeId = v, + ); + }, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))), + const SizedBox(width: 16), + Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))), + ], + ), + const Divider(height: 32), + + Column( + children: _parameters.map((param) { + return _buildParameterListItem( + icon: param['icon'] as IconData, + label: param['label'] as String, + unit: param['unit'] as String, + controller: param['controller'] as TextEditingController, + ); + }).toList(), + ), + + const SizedBox(height: 32), + ElevatedButton( + onPressed: _goToNextStep, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('Next'), + ), + ], + ), + ); + } + + Widget _buildParameterListItem({ + required IconData icon, + required String label, + required String unit, + required TextEditingController controller, + }) { + final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); + final String displayValue = isMissing ? '-.--' : controller.text; + final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: ListTile( + leading: Icon(icon, color: Theme.of(context).primaryColor, size: 32), + title: Text(displayLabel), + trailing: Text( + displayValue, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isMissing ? Colors.grey : Theme.of(context).colorScheme.primary), + ), + ), + ); + } + + Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) { + final isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected; + final isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting; + + Color statusColor = isConnected ? Colors.green : Colors.red; + String statusText = isConnected ? 'Connected to ${deviceName ?? 'device'}' : 'Disconnected'; + if (isConnecting) { + statusColor = Colors.orange; + statusText = 'Connecting...'; + } + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text(statusText, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 16), + if (isConnecting || _isLoading) + const CircularProgressIndicator() + else if (isConnected) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), + label: Text(_isAutoReading ? 'Stop Reading' : 'Start Reading'), + onPressed: () => _toggleAutoReading(type), + style: ElevatedButton.styleFrom( + backgroundColor: _isAutoReading ? Colors.orange : Colors.green, + foregroundColor: Colors.white, + ), + ), + TextButton.icon( + icon: const Icon(Icons.link_off), + label: const Text('Disconnect'), + onPressed: () => _disconnect(type), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ) + ], + ) + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_4_summary.dart b/lib/screens/river/manual/widgets/river_in_situ_step_4_summary.dart new file mode 100644 index 0000000..4094d34 --- /dev/null +++ b/lib/screens/river/manual/widgets/river_in_situ_step_4_summary.dart @@ -0,0 +1,229 @@ +// lib/screens/river/manual/widgets/river_in_situ_step_4_summary.dart + +import 'dart:io'; +import 'package:flutter/material.dart'; + +// CHANGED: Import river-specific data model +import '../../../../models/river_in_situ_sampling_data.dart'; + +// CHANGED: Renamed class for river context +class RiverInSituStep4Summary extends StatelessWidget { + // CHANGED: Expects river-specific data model + final RiverInSituSamplingData data; + final VoidCallback onSubmit; + final bool isLoading; + + const RiverInSituStep4Summary({ + super.key, + required this.data, + required this.onSubmit, + required this.isLoading, + }); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + Text( + "Please review all information before submitting.", + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + _buildSectionCard( + context, + "Sampling & Station Details", + [ + _buildDetailRow("1st Sampler:", data.firstSamplerName), + _buildDetailRow("2nd Sampler:", data.secondSampler?['first_name']?.toString()), + _buildDetailRow("Sampling Date:", data.samplingDate), + _buildDetailRow("Sampling Time:", data.samplingTime), + _buildDetailRow("Sampling Type:", data.samplingType), + _buildDetailRow("Sample ID Code:", data.sampleIdCode), + const Divider(height: 20), + _buildDetailRow("State:", data.selectedStateName), + _buildDetailRow("Category:", data.selectedCategoryName), + // CHANGED: Use river-specific station keys + _buildDetailRow("Station Code:", data.selectedStation?['r_man_station_code']?.toString()), + _buildDetailRow("Station Name:", data.selectedStation?['r_man_station_name']?.toString()), + _buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"), + ], + ), + + _buildSectionCard( + context, + "Location & On-Site Info", + [ + _buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"), + _buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"), + if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty) + _buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks), + const Divider(height: 20), + _buildDetailRow("Weather:", data.weather), + // CHANGED: Use river-specific fields + _buildDetailRow("Water Level:", data.waterLevel), + _buildDetailRow("River Condition:", data.riverCondition), + _buildDetailRow("Event Remarks:", data.eventRemarks), + _buildDetailRow("Lab Remarks:", data.labRemarks), + ], + ), + + _buildSectionCard( + context, + "Attached Photos", + [ + // CHANGED: Use river-specific image properties and labels + _buildImageCard("Left Bank View", data.leftBankViewImage), + _buildImageCard("Right Bank View", data.rightBankViewImage), + _buildImageCard("Filling Water into Bottle", data.waterFillingImage), + _buildImageCard("Water Color in Bottle", data.waterColorImage), + _buildImageCard("Examine Preservative (pH paper)", data.phPaperImage), + const Divider(height: 24), + Text("Optional Photos", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + _buildImageCard("Optional Photo 1", data.optionalImage1, remark: data.optionalRemark1), + _buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2), + _buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3), + _buildImageCard("Optional Photo 4", data.optionalImage4, remark: data.optionalRemark4), + ], + ), + + _buildSectionCard( + context, + "Captured Parameters", + [ + _buildDetailRow("Sonde ID:", data.sondeId), + _buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"), + const Divider(height: 20), + _buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity?.toStringAsFixed(0)), + _buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss?.toStringAsFixed(2)), + _buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage?.toStringAsFixed(2)), + // NOTE: If you add river-specific parameters, display them here. + ], + ), + + const SizedBox(height: 24), + isLoading + ? const Center(child: CircularProgressIndicator()) + : ElevatedButton.icon( + onPressed: onSubmit, + icon: const Icon(Icons.cloud_upload), + label: const Text('Confirm & Submit'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 16), + ], + ); + } + + Widget _buildSectionCard(BuildContext context, String title, List children) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8.0), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + const Divider(height: 20, thickness: 1), + ...children, + ], + ), + ), + ); + } + + Widget _buildDetailRow(String label, String? value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: Text(value != null && value.isNotEmpty ? value : 'N/A', style: const TextStyle(fontSize: 16)), + ), + ], + ), + ); + } + + Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required String? value}) { + final bool isMissing = value == null; + final String displayValue = isMissing ? 'N/A' : '$value ${unit}'.trim(); + + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(icon, color: Theme.of(context).primaryColor, size: 28), + title: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + trailing: Text( + displayValue, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: isMissing ? Colors.grey : null, + fontWeight: isMissing ? null : FontWeight.bold, + ), + ), + ); + } + + Widget _buildImageCard(String title, File? image, {String? remark}) { + final bool hasRemark = remark != null && remark.isNotEmpty; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + if (image != null) + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.file(image, key: UniqueKey(), height: 200, width: double.infinity, fit: BoxFit.cover), + ) + else + Container( + height: 100, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8.0), + border: Border.all(color: Colors.grey[300]!)), + child: const Center(child: Text('No Image Attached', style: TextStyle(color: Colors.grey))), + ), + if (hasRemark) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic)), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/river_home_page.dart b/lib/screens/river/river_home_page.dart new file mode 100644 index 0000000..2094ecf --- /dev/null +++ b/lib/screens/river/river_home_page.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +// Re-defining SidebarItem here for self-containment, +// ideally this would be in a shared utility file if used across many screens. +class SidebarItem { + final IconData? icon; + final String label; + final String? route; + final List? children; + final bool isParent; + final String? imagePath; + + const SidebarItem({ + this.icon, + required this.label, + this.route, + this.children, + this.isParent = false, + this.imagePath, + }) : assert(icon != null || imagePath != null, 'Either icon or imagePath must be provided'); +} + +class RiverHomePage extends StatelessWidget { + const RiverHomePage({super.key}); + + // Define River's sub-menu structure (Manual, Continuous, Investigative) + final List _riverSubMenus = const [ + SidebarItem( + icon: Icons.handshake, + label: "Manual", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/manual/dashboard'), + SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/manual/report'), + SidebarItem(icon: Icons.date_range, label: "Triennial Sampling", route: '/river/manual/triennial'), + SidebarItem(icon: Icons.article, label: "Data Log", route: '/river/manual/data-log'), + SidebarItem(icon: Icons.image, label: "Image Request", route: '/river/manual/image-request'), + ], + ), + SidebarItem( + icon: Icons.trending_up, + label: "Continuous", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/continuous/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/river/continuous/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/river/continuous/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/continuous/report'), + ], + ), + SidebarItem( + icon: Icons.search, + label: "Investigative", + isParent: true, + children: [ + SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/investigative/dashboard'), + SidebarItem(icon: Icons.info, label: "Overview", route: '/river/investigative/overview'), + SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'), + SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("River Department"), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Explore River Monitoring Sections", + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + Column( + children: _riverSubMenus.map((category) { + return _buildCategorySection(context, category); + }).toList(), + ), + ], + ), + ), + ); + } + + // Method to build each category section (Manual, Continuous, Investigative) + Widget _buildCategorySection(BuildContext context, SidebarItem category) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category header (icon + label) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon(category.icon, size: 30, color: Theme.of(context).iconTheme.color), + const SizedBox(width: 12), + Text( + category.label, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const Divider(height: 24, thickness: 1, color: Colors.white24), // Divider below category title + // Grid of sub-menu items + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, // 3 columns for sub-menu items + crossAxisSpacing: 0.0, // Removed horizontal spacing + mainAxisSpacing: 0.0, // Removed vertical spacing + childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content + ), + itemCount: category.children?.length ?? 0, + itemBuilder: (context, index) { + final subItem = category.children![index]; + return InkWell( + onTap: () { + if (subItem.route != null) { + Navigator.pushNamed(context, subItem.route!); + } + }, + borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid + child: Padding( + padding: const EdgeInsets.all(8.0), // Padding around the row content + child: Row( // Changed from Column to Row + mainAxisAlignment: MainAxisAlignment.start, // Align content to start + children: [ + subItem.icon != null + ? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24 + : const SizedBox.shrink(), + const SizedBox(width: 8), // Space between icon and text (horizontal) + Expanded( // Allow text to take remaining space + child: Text( + subItem.label, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11 + textAlign: TextAlign.left, // Align text to left + overflow: TextOverflow.ellipsis, + maxLines: 1, // Single line for label + ), + ), + ], + ), + ), + ); + }, + ), + const SizedBox(height: 16), // Reduced gap after each category group + ], + ); + } +} diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart new file mode 100644 index 0000000..ce42afe --- /dev/null +++ b/lib/screens/settings.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/services/settings_service.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + final SettingsService _settingsService = SettingsService(); + bool _isSyncingData = false; + bool _isSyncingSettings = false; + + String _inSituChatId = 'Loading...'; + String _tarballChatId = 'Loading...'; + + final TextEditingController _tarballSearchController = TextEditingController(); + String _tarballSearchQuery = ''; + final TextEditingController _manualSearchController = TextEditingController(); + String _manualSearchQuery = ''; + final TextEditingController _riverManualSearchController = TextEditingController(); + String _riverManualSearchQuery = ''; + final TextEditingController _riverTriennialSearchController = TextEditingController(); + String _riverTriennialSearchQuery = ''; + + @override + void initState() { + super.initState(); + _loadCurrentSettings(); + _tarballSearchController.addListener(_onTarballSearchChanged); + _manualSearchController.addListener(_onManualSearchChanged); + _riverManualSearchController.addListener(_onRiverManualSearchChanged); + _riverTriennialSearchController.addListener(_onRiverTriennialSearchChanged); + } + + @override + void dispose() { + _tarballSearchController.dispose(); + _manualSearchController.dispose(); + _riverManualSearchController.dispose(); + _riverTriennialSearchController.dispose(); + super.dispose(); + } + + Future _loadCurrentSettings() async { + final inSituId = await _settingsService.getInSituChatId(); + final tarballId = await _settingsService.getTarballChatId(); + if (mounted) { + setState(() { + _inSituChatId = inSituId.isNotEmpty ? inSituId : 'Not Set'; + _tarballChatId = tarballId.isNotEmpty ? tarballId : 'Not Set'; + }); + } + } + + void _onTarballSearchChanged() { + setState(() { _tarballSearchQuery = _tarballSearchController.text; }); + } + + void _onManualSearchChanged() { + setState(() { _manualSearchQuery = _manualSearchController.text; }); + } + + void _onRiverManualSearchChanged() { + setState(() { _riverManualSearchQuery = _riverManualSearchController.text; }); + } + + void _onRiverTriennialSearchChanged() { + setState(() { _riverTriennialSearchQuery = _riverTriennialSearchController.text; }); + } + + // --- FIXED: This method now uses try/catch to handle success and failure --- + Future _manualDataSync() async { + if (_isSyncingData) return; + setState(() => _isSyncingData = true); + + final auth = Provider.of(context, listen: false); + + try { + // This function doesn't return a value, so we don't assign it to a variable. + await auth.syncAllData(forceRefresh: true); + + // If no error was thrown, the sync was successful. + if (mounted) { + _showSnackBar('Data synced successfully.', isError: false); + } + } catch (e) { + // If an error was thrown during the sync, we catch it here. + if (mounted) { + _showSnackBar('Data sync failed. Please check your connection.', isError: true); + } + } finally { + // This will run whether the sync succeeded or failed. + if (mounted) { + setState(() => _isSyncingData = false); + } + } + } + + Future _manualSettingsSync() async { + if (_isSyncingSettings) return; + setState(() => _isSyncingSettings = true); + + final success = await _settingsService.syncFromServer(); + + if (mounted) { + final message = success ? 'Telegram settings synced successfully.' : 'Failed to sync settings.'; + _showSnackBar(message, isError: !success); + if (success) { + await _loadCurrentSettings(); + } + setState(() => _isSyncingSettings = false); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Theme.of(context).colorScheme.error : Colors.green, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final auth = Provider.of(context); + final lastSync = auth.lastSyncTimestamp; + + // Filtering logic is unchanged + final filteredTarballStations = auth.tarballStations?.where((station) { + final stationName = station['tbl_station_name']?.toLowerCase() ?? ''; + final stationCode = station['tbl_station_code']?.toLowerCase() ?? ''; + final query = _tarballSearchQuery.toLowerCase(); + return stationName.contains(query) || stationCode.contains(query); + }).toList(); + final filteredManualStations = auth.manualStations?.where((station) { + final stationName = station['man_station_name']?.toLowerCase() ?? ''; + final stationCode = station['man_station_code']?.toLowerCase() ?? ''; + final query = _manualSearchQuery.toLowerCase(); + return stationName.contains(query) || stationCode.contains(query); + }).toList(); + final filteredRiverManualStations = auth.riverManualStations?.where((station) { + final riverName = station['sampling_river']?.toLowerCase() ?? ''; + final stationCode = station['sampling_station_code']?.toLowerCase() ?? ''; + final basinName = station['sampling_basin']?.toLowerCase() ?? ''; + final query = _riverManualSearchQuery.toLowerCase(); + return riverName.contains(query) || stationCode.contains(query) || basinName.contains(query); + }).toList(); + final filteredRiverTriennialStations = auth.riverTriennialStations?.where((station) { + final riverName = station['triennial_river']?.toLowerCase() ?? ''; + final stationCode = station['triennial_station_code']?.toLowerCase() ?? ''; + final basinName = station['triennial_basin']?.toLowerCase() ?? ''; + final query = _riverTriennialSearchQuery.toLowerCase(); + return riverName.contains(query) || stationCode.contains(query) || basinName.contains(query); + }).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text("Settings"), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Synchronization", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Last Data Sync:", style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text(lastSync != null ? DateFormat('yyyy-MM-dd HH:mm:ss').format(lastSync) : 'Never', style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _isSyncingData ? null : _manualDataSync, + icon: _isSyncingData ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.cloud_sync), + label: Text(_isSyncingData ? 'Syncing Data...' : 'Sync App Data'), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)), + ), + ], + ), + ), + ), + const SizedBox(height: 32), + + Text("Telegram Alert Settings", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.telegram), + title: const Text('Marine In-Situ Chat ID'), + subtitle: Text(_inSituChatId), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.telegram), + title: const Text('Marine Tarball Chat ID'), + subtitle: Text(_tarballChatId), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _isSyncingSettings ? null : _manualSettingsSync, + icon: _isSyncingSettings ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.settings_backup_restore), + label: Text(_isSyncingSettings ? 'Syncing Settings...' : 'Sync Telegram Settings'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + minimumSize: const Size(double.infinity, 50), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 32), + + Text("Marine Tarball Stations (${filteredTarballStations?.length ?? 0} found)", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField(controller: _tarballSearchController, decoration: InputDecoration(labelText: 'Search Tarball Stations', hintText: 'Search by name or code', prefixIcon: const Icon(Icons.search), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), suffixIcon: _tarballSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _tarballSearchController.clear()) : null)), + const SizedBox(height: 16), + _buildStationList(filteredTarballStations, 'No matching tarball stations found.', 'No tarball stations available. Sync to download.', (station) => ListTile(title: Text(station['tbl_station_name'] ?? 'N/A'), subtitle: Text('Code: ${station['tbl_station_code'] ?? 'N/A'}'))), + ], + ), + ), + ), + const SizedBox(height: 32), + + Text("Marine Manual Stations (${filteredManualStations?.length ?? 0} found)", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField(controller: _manualSearchController, decoration: InputDecoration(labelText: 'Search Manual Stations', hintText: 'Search by name or code', prefixIcon: const Icon(Icons.search), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), suffixIcon: _manualSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _manualSearchController.clear()) : null)), + const SizedBox(height: 16), + _buildStationList(filteredManualStations, 'No matching manual stations found.', 'No manual stations available. Sync to download.', (station) => ListTile(title: Text(station['man_station_name'] ?? 'N/A'), subtitle: Text('Code: ${station['man_station_code'] ?? 'N/A'}'))), + ], + ), + ), + ), + const SizedBox(height: 32), + + Text("River Manual Stations (${filteredRiverManualStations?.length ?? 0} found)", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField(controller: _riverManualSearchController, decoration: InputDecoration(labelText: 'Search River Manual Stations', hintText: 'Search by river, basin, or code', prefixIcon: const Icon(Icons.search), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), suffixIcon: _riverManualSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _riverManualSearchController.clear()) : null)), + const SizedBox(height: 16), + _buildStationList(filteredRiverManualStations, 'No matching river manual stations found.', 'No river manual stations available. Sync to download.', (station) => ListTile(title: Text(station['sampling_river'] ?? 'N/A'), subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text('Code: ${station['sampling_station_code'] ?? 'N/A'}'), Text('Basin: ${station['sampling_basin'] ?? 'N/A'}'), Text('State: ${station['state_name'] ?? 'N/A'}')]))), + ], + ), + ), + ), + const SizedBox(height: 32), + + Text("River Triennial Stations (${filteredRiverTriennialStations?.length ?? 0} found)", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField(controller: _riverTriennialSearchController, decoration: InputDecoration(labelText: 'Search River Triennial Stations', hintText: 'Search by river, basin, or code', prefixIcon: const Icon(Icons.search), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), suffixIcon: _riverTriennialSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _riverTriennialSearchController.clear()) : null)), + const SizedBox(height: 16), + _buildStationList(filteredRiverTriennialStations, 'No matching river triennial stations found.', 'No river triennial stations available. Sync to download.', (station) => ListTile(title: Text(station['triennial_river'] ?? 'N/A'), subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text('Code: ${station['triennial_station_code'] ?? 'N/A'}'), Text('Basin: ${station['triennial_basin'] ?? 'N/A'}'), Text('State: ${station['state_name'] ?? 'N/A'}')]))), + ], + ), + ), + ), + const SizedBox(height: 32), + + Text("General Settings", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + ListTile(leading: const Icon(Icons.info_outline), title: const Text('App Version'), subtitle: const Text('1.0.0')), + ListTile(leading: const Icon(Icons.privacy_tip_outlined), title: const Text('Privacy Policy'), onTap: () {}), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildStationList(List>? stations, String noMatchText, String noDataText, Widget Function(Map) itemBuilder) { + if (stations == null || stations.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + _isSyncingData ? 'Loading...' : (stations == null ? noDataText : noMatchText), + ), + ), + ); + } + + return SizedBox( + height: 250, + child: ListView.builder( + itemCount: stations.length, + itemBuilder: (context, index) { + final station = stations[index]; + return itemBuilder(station); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/serial/serial_manager.dart b/lib/serial/serial_manager.dart new file mode 100644 index 0000000..024e62a --- /dev/null +++ b/lib/serial/serial_manager.dart @@ -0,0 +1,522 @@ +// lib/serial/serial_manager.dart +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; // For debugPrint +import 'package:usb_serial/usb_serial.dart'; + +import 'utils/converter.dart'; +import 'utils/crc_calculator.dart'; +import 'utils/parameter_helper.dart'; + +enum SerialConnectionState { disconnected, connecting, connected } + +/// Manages the connection and data communication with a USB Serial device. +class SerialManager { + UsbPort? _port; + // Using ValueNotifier to easily notify UI about connection state changes. + final ValueNotifier connectionState = ValueNotifier( + SerialConnectionState.disconnected, + ); + // ValueNotifier to expose the connected device's name. + final ValueNotifier connectedDeviceName = ValueNotifier(null); + + // --- ADDED: ValueNotifier for Sonde ID --- + // This will be updated when the Sonde ID is parsed from Level 0 data. + final ValueNotifier sondeId = ValueNotifier(null); + + // StreamController to broadcast parsed data readings to multiple listeners. + final StreamController> _dataStreamController = + StreamController>.broadcast(); + Stream> get dataStream => _dataStreamController.stream; + + // --- Protocol State Variables --- + int _runningCounter = 0; // Sequence number for commands + int _communicationLevel = 0; // Tracks the current step in the 3-level protocol + String? _parentAddress; // Address parsed from Level 0 response + String? _serialNumber; // Serial number parsed from Level 0 response (used internally for now) + List _parameterList = []; // List of parameters parsed from Level 1 response + + Timer? _dataRequestTimer; // Timer for periodic auto-reading + Timer? _responseTimeoutTimer; // Timer to detect if a response is not received in time + + // Buffer to accumulate incoming serial data, as messages may arrive in chunks. + final StringBuffer _responseBuffer = StringBuffer(); + // String to look for at the start of an expected response (e.g., '7E02 + seqNo') + String? _lookupString; + // Flag to prevent sending new commands before the current read cycle is complete. + bool _isReading = false; + + /// Fetches a list of available USB devices connected to the Android device. + Future> getAvailableDevices() async { + return UsbSerial.listDevices(); + } + + /// Connects to the specified USB device. + /// Handles opening the port, setting parameters, and starting the input stream listener. + Future connect(UsbDevice device) async { + // Prevent connecting if already connected + if (connectionState.value == SerialConnectionState.connected) { + debugPrint("SerialManager: Already connected. Disconnecting existing connection."); + // Now that disconnect() is async and returns Future, awaiting it is correct. + await disconnect(); // Disconnect cleanly before new connection + } + + try { + connectionState.value = SerialConnectionState.connecting; + connectedDeviceName.value = device.productName ?? 'Unknown Device'; // Update device name early + + _port = await device.create(); + if (_port == null) { + debugPrint("SerialManager: Failed to create USB serial port for device: ${device.productName}."); + // No need to call disconnect, as nothing was connected yet. + connectionState.value = SerialConnectionState.disconnected; // Reset state + connectedDeviceName.value = null; // Clear device name + throw Exception("Failed to create USB serial port."); + } + + bool openResult = await _port!.open(); + if (!openResult) { + debugPrint("SerialManager: Failed to open USB serial port for device: ${device.productName}."); + _port = null; // Clear port reference + connectionState.value = SerialConnectionState.disconnected; // Reset state + connectedDeviceName.value = null; // Clear device name + throw Exception("Failed to open USB serial port."); + } + + await _port!.setDTR(true); + await _port!.setRTS(true); + + _port!.setPortParameters( + 115200, // Baud Rate + UsbPort.DATABITS_8, + UsbPort.STOPBITS_1, + UsbPort.PARITY_NONE, + ); + + // Listen for incoming data from the USB serial port. + _port!.inputStream!.listen( + _onDataReceived, + onError: (e) { + debugPrint("SerialManager: Stream Error: $e"); + // Since disconnect() is now async, calling it directly here without await is fine + // because we don't need to wait for it within the error handler. + disconnect(); + }, + onDone: () { + debugPrint("SerialManager: Stream Closed"); + // Same here, no need to await within onDone. + disconnect(); + }, + ); + + connectionState.value = SerialConnectionState.connected; + debugPrint("SerialManager: Connected to ${connectedDeviceName.value}"); + + // Start the live reading cycle immediately after connecting + startLiveReading(); + + } catch (e) { + debugPrint("SerialManager: Connection Error: $e"); + // Ensure all states are reset on error during connection + connectionState.value = SerialConnectionState.disconnected; + connectedDeviceName.value = null; + sondeId.value = null; // Clear Sonde ID + _port = null; + _responseBuffer.clear(); + _isReading = false; + _cancelTimeout(); + rethrow; // Re-throw the exception for UI handling + } + } + + /// Disconnects from the currently connected USB device. + /// Cleans up resources: cancels timers, closes port, resets state. + // MODIFIED: Changed from void to Future and added async. + Future disconnect() async { + if (connectionState.value != SerialConnectionState.disconnected) { + debugPrint("SerialManager: Disconnecting..."); + stopAutoReading(); // Stop any active auto-reading timer and timeout + await _port?.close(); // Now correctly awaiting the close operation + _port = null; // Clear port reference + connectedDeviceName.value = null; // Clear device name + sondeId.value = null; // Clear Sonde ID on disconnect + connectionState.value = SerialConnectionState.disconnected; // Update connection state + _responseBuffer.clear(); // Clear any buffered data + _isReading = false; // Reset reading flag + _communicationLevel = 0; // Reset communication level + _parentAddress = null; + _serialNumber = null; + _parameterList.clear(); + debugPrint("SerialManager: Disconnected."); + } + } + + /// Starts a periodic timer to automatically request data from the device. + void startAutoReading({Duration interval = const Duration(seconds: 5)}) { + stopAutoReading(); // Stop any existing auto-reading timer first + if (connectionState.value == SerialConnectionState.connected) { + //startLiveReading(); // Initiate the first reading immediately + // Set up a periodic timer to call startLiveReading at the specified interval. + _dataRequestTimer = Timer.periodic(interval, (_) => startLiveReading()); + debugPrint("SerialManager: Auto reading started with interval ${interval.inSeconds}s."); + } else { + debugPrint("SerialManager: Cannot start auto reading, not connected."); + } + } + + /// Stops the automatic data refresh timer. + void stopAutoReading() { + _dataRequestTimer?.cancel(); // Cancel the periodic timer + _dataRequestTimer = null; // Clear timer reference + _cancelTimeout(); // Also cancel any pending response timeout + _isReading = false; // Ensure the reading flag is reset + debugPrint("SerialManager: Auto reading stopped."); + } + + /// Initiates a full 3-step data reading sequence (Level 0, Level 1, Level 2). + /// This function prevents overlapping read cycles using the `_isReading` flag. + void startLiveReading() { + // Only proceed if connected and not already in a reading cycle + if (connectionState.value != SerialConnectionState.connected || _isReading) { + debugPrint("SerialManager: Cannot start live reading. Connected: ${connectionState.value == SerialConnectionState.connected}, Is Reading: $_isReading"); + return; + } + _isReading = true; // Mark as busy to prevent re-entry + debugPrint("--- SerialManager: Starting New Read Cycle ---"); + _communicationLevel = 0; // Start at communication level 0 + _responseBuffer.clear(); // Clear the buffer for a fresh start + _lookupString = null; // Reset lookup string until a new command is sent + _sendCommand(0); // Send the Level 0 command + } + + /// Callback function for the serial port's input stream. + /// Appends incoming data chunks to a buffer and attempts to process it. + void _onDataReceived(Uint8List data) { + if (data.isEmpty) return; + String responseHex = Converter.byteArrayToHexString(data); + _responseBuffer.write(responseHex); // Append to the shared buffer + // debugPrint("SerialManager: Received Chunk (Buffer: ${_responseBuffer.length} chars): $responseHex"); // Too verbose normally + _processBuffer(); // Attempt to process the buffer for complete messages + } + + /// Attempts to extract and process complete messages from the `_responseBuffer`. + /// Handles partial messages, garbage data, and CRC validation. + void _processBuffer() { + String buffer = _responseBuffer.toString(); + // Cannot process without an expected lookup string (set after sending a command) + if (_lookupString == null) { + // debugPrint("SerialManager: _processBuffer: No lookup string set. Waiting for command."); // Too verbose + return; + } + + int startIndex = buffer.indexOf(_lookupString!); // Find the start of the expected message + if (startIndex == -1) { + // debugPrint("SerialManager: _processBuffer: Lookup string not found in buffer. Waiting for more data."); // Too verbose + return; // Expected start of message not found, wait for more data + } + + // Discard any data before the expected start of the message (garbage or old data) + if (startIndex > 0) { + debugPrint("SerialManager: _processBuffer: Discarding ${startIndex} garbage characters from buffer."); + buffer = buffer.substring(startIndex); // Remove leading garbage + _responseBuffer.clear(); // Clear and rewrite the buffer to contain only valid data from startIndex + _responseBuffer.write(buffer); + } + + // A minimum of 34 hex characters are needed to parse the dataBlockLength (offset 30, length 4) + if (buffer.length < 34) { + // debugPrint("SerialManager: _processBuffer: Buffer too short for header (${buffer.length} < 34). Waiting for more data."); // Too verbose + return; // Not enough data to even read the length field + } + + try { + // Parse the data block length from the message header + int dataBlockLength = int.parse(buffer.substring(30, 34), radix: 16); + // Calculate the total expected length of the complete message (Header + Data Block + CRC) + int totalMessageLength = 34 + (dataBlockLength * 2) + 4; // 34 chars for header, dataBlockLength bytes * 2 chars/byte, 4 chars for CRC + + // If the buffer contains a complete message + if (buffer.length >= totalMessageLength) { + _cancelTimeout(); // Message received, cancel the response timeout timer + String completeResponse = buffer.substring(0, totalMessageLength); + + // --- CRC Validation --- + Uint8List responseBytes = Converter.hexStringToByteArray(completeResponse); + // CRC is calculated over the entire message *excluding* the final 2 CRC bytes themselves + Uint8List dataBytesForCrc = responseBytes.sublist(0, responseBytes.length - 2); + String calculatedCrc = Crc16Ccitt.computeCrc16Ccitt(dataBytesForCrc); + String receivedCrc = completeResponse.substring(completeResponse.length - 4); // Last 4 hex chars are the CRC + + if (calculatedCrc.toUpperCase() != receivedCrc.toUpperCase()) { + debugPrint("SerialManager: CRC Mismatch! Calculated: $calculatedCrc, Received: $receivedCrc for response: $completeResponse. Discarding message."); + // Discard the invalid message from the buffer and attempt to process the rest + _responseBuffer.clear(); + _responseBuffer.write(buffer.substring(totalMessageLength)); + _isReading = false; // Consider ending the cycle or signaling an error that might require a retry. + if(_responseBuffer.isNotEmpty) _processBuffer(); // Check if more messages are in the buffer + return; + } + + // Message is valid. Remove it from the buffer. + _responseBuffer.clear(); + _responseBuffer.write(buffer.substring(totalMessageLength)); + + debugPrint("SerialManager: Processing Full Valid Response (Lvl: $_communicationLevel): $completeResponse"); + _handleResponse(completeResponse); // Route the complete message for parsing + + // Recursively call _processBuffer in case multiple messages arrived in one chunk, + // or a new message started immediately after the processed one. + if(_responseBuffer.isNotEmpty) _processBuffer(); + } + } catch (e) { + debugPrint("SerialManager: Buffer processing error: $e. Resetting cycle and clearing buffer."); + _responseBuffer.clear(); + _isReading = false; // End this cycle on error + } + } + + /// Dispatches the complete and validated response string to the appropriate handler + /// based on the current communication level. + void _handleResponse(String response) { + switch (_communicationLevel) { + case 0: + _handleResponseLevel0(response); + break; + case 1: + _handleResponseLevel1(response); + break; + case 2: + _handleResponseLevel2(response); + break; + default: + debugPrint("SerialManager: Unknown communication level: $_communicationLevel for response: $response"); + _isReading = false; // Ensure reading lock is released if level is invalid + } + } + + /// Handles and parses the response for communication Level 0. + /// Extracts parent address and serial number. + void _handleResponseLevel0(String responseHex) { + try { + // Minimum length check to ensure substrings are safe + // 34 (header) + 40 (data block for L0) + 4 (CRC) = 78 expected min length for full response + // It looks like your previous code assumed data block starts at 34 and contains 4 bytes (8 chars) + // for parent address at offset 86, and 9 bytes (18 chars) for serial at 68. + // Let's refine based on typical structure. + // If `responseHex.substring(68, 86)` and `responseHex.substring(86, 94)` are correct from your sensor, + // then total length for serial number (18) + parent address (8) + other header/footer must be respected. + // 18 chars for serial, 8 chars for parent. Total 26 chars / 2 = 13 bytes data length. + // So, total expected response length = 34 + (13*2) + 4 = 34 + 26 + 4 = 64. + // If your sensor gives 94 chars: 34 (header) + (94-34-4=56 data chars) + 4 (CRC) -> 28 bytes data. + // This means offsets 68-86 and 86-94 might be within a larger data block. + // This response example is for a specific sensor protocol. Adjust offsets if yours differs. + if (responseHex.length < 94) { // Assuming 94 is the correct full length including all L0 data. + debugPrint("SerialManager: Error: Level 0 response too short. Length: ${responseHex.length} (expected at least 94)"); + _isReading = false; + return; + } + + // Re-evaluate based on your specific protocol: + // The offsets 68-86 and 86-94 suggest a specific structure within the *data block* of the response. + // The `dataBlockLength` field (at 30-34) specifies the length of the data portion *after* the header. + // Let's assume these substrings are correct as per your sensor's documentation: + _parentAddress = responseHex.substring(86, 94); // Extract parent address (8 hex chars = 4 bytes) + _serialNumber = Converter.hexToAscii(responseHex.substring(68, 86)); // Extract serial number (18 hex chars = 9 bytes, then convert to ASCII) + + debugPrint("SerialManager: Parsed L0 -> Address: $_parentAddress, Serial: $_serialNumber"); + + // --- Update sondeId ValueNotifier here --- + if (_serialNumber != null && _serialNumber!.isNotEmpty) { + sondeId.value = _serialNumber; + debugPrint("SerialManager: Updated Sonde ID: ${sondeId.value}"); + } else { + sondeId.value = "N/A"; // Or handle empty/null serial number appropriately + } + + + if (_parentAddress != "00000000") { // Check for valid parent address + _communicationLevel = 1; // Advance to Level 1 + // Delay before sending next command to allow device time to process + Future.delayed(const Duration(milliseconds: 200), () => _sendCommand(1)); + } else { + debugPrint("SerialManager: Parent address is 00000000, not proceeding to Level 1. Ending cycle."); + _isReading = false; // End the cycle if parent address is invalid + } + } catch (e) { + debugPrint("SerialManager: Error parsing L0: $e"); + _isReading = false; // End this cycle on error + } + } + + /// Handles and parses the response for communication Level 1. + /// Extracts the list of available parameters. + void _handleResponseLevel1(String responseHex) { + try { + if (responseHex.length < 34) { + debugPrint("SerialManager: Error: Level 1 response too short for dataBlockLength. Length: ${responseHex.length}"); + _isReading = false; + return; + } + int dataBlockLength = int.parse(responseHex.substring(30, 34), radix: 16); + int expectedParamsBlockEnd = 34 + (dataBlockLength * 2); + + if (expectedParamsBlockEnd > responseHex.length) { + debugPrint("SerialManager: Error: Level 1 parameters block calculated end index ($expectedParamsBlockEnd) exceeds received data length (${responseHex.length}). dataBlockLength was: $dataBlockLength (0x${dataBlockLength.toRadixString(16).padLeft(4, '0')})"); + _isReading = false; + return; + } + + String paramsBlock = responseHex.substring(34, expectedParamsBlockEnd); + _parameterList.clear(); + + // Each parameter entry is 6 hex characters (3 bytes). + // Parameter code (2 bytes) is typically at index 2-5 within each 6-char block. + // Example: '005A00' -> code '5A00' + for (int i = 0; i <= paramsBlock.length - 6; i += 6) { + String parameterCode = paramsBlock.substring(i + 2, i + 6); + _parameterList.add(ParameterHelper.getDescription(parameterCode)); + } + debugPrint("SerialManager: Parsed L1 -> Parameters: $_parameterList"); + _communicationLevel = 2; // Advance to Level 2 + Future.delayed(const Duration(milliseconds: 200), () => _sendCommand(2)); + } catch (e) { + debugPrint("SerialManager: Error parsing L1: $e"); + _isReading = false; + } + } + + /// Handles and parses the response for communication Level 2. + /// Extracts the actual values for the previously identified parameters. + void _handleResponseLevel2(String responseHex) { + try { + if (responseHex.length < 34) { + debugPrint("SerialManager: Error: Level 2 response too short for dataBlockLength. Length: ${responseHex.length}"); + _isReading = false; + return; + } + int dataBlockLength = int.parse(responseHex.substring(30, 34), radix: 16); + int expectedValuesBlockEnd = 34 + (dataBlockLength * 2); + + if (expectedValuesBlockEnd > responseHex.length) { + debugPrint("SerialManager: Error: Level 2 values block calculated end index ($expectedValuesBlockEnd) exceeds received data length (${responseHex.length}). dataBlockLength was: $dataBlockLength (0x${dataBlockLength.toRadixString(16).padLeft(4, '0')})"); + _isReading = false; + return; + } + + String valuesBlock = responseHex.substring(34, expectedValuesBlockEnd); + List values = []; + + // Each value is 8 hex characters (4 bytes) representing a float + for (int i = 0; i <= valuesBlock.length - 8; i += 8) { + String valueHex = valuesBlock.substring(i, i + 8); + try { + values.add(Converter.hexToFloat(valueHex)); + } catch (e) { + debugPrint("SerialManager: Error converting hex '$valueHex' to float in Level 2: $e"); + values.add(double.nan); // Add NaN or a default error value if conversion fails + } + } + + // Ensure the number of parsed parameters matches the number of parsed values. + if (_parameterList.length == values.length) { + Map finalReadings = Map.fromIterables(_parameterList, values); + _dataStreamController.add(finalReadings); // Broadcast the final parsed readings + debugPrint("SerialManager: Final Parsed Readings: $finalReadings"); + } else { + debugPrint("SerialManager: L2 Data Mismatch: ${values.length} values for ${_parameterList.length} parameters. Parameter list: $_parameterList, Values: $values"); + } + } catch (e) { + debugPrint("SerialManager: Error parsing L2: $e"); + } finally { + _isReading = false; // Mark the read cycle as complete, allowing the next one to start + debugPrint("--- SerialManager: Read Cycle Complete ---"); + } + } + + /// Constructs and sends the appropriate command based on the current communication level. + void _sendCommand(int level) async { + // Generate a sequence number (0-255), padded to 2 hex characters. + String seqNo = (_runningCounter++ & 255).toRadixString(16).padLeft(2, '0').toUpperCase(); + + // Set the lookup string for the expected response. This is the start of the message + // that the `_processBuffer` will look for to identify a complete response. + _lookupString = '7E02${seqNo}'; // Assuming responses start with 7E02 and the same sequence number. + + String commandBody; + switch (level) { + case 0: + commandBody = '0000000002000000200000010000'; // Command for Level 0 + break; + case 1: + if (_parentAddress == null) { + debugPrint("SerialManager: Error: _parentAddress is null for Level 1 command. Cannot send. Ending cycle."); + _isReading = false; // Release the reading lock + // Call disconnect (now async void) without await here as we don't need to block + disconnect(); // Disconnect due to invalid state + return; + } + commandBody = '${_parentAddress}02000000200000180000'; // Command for Level 1, includes parent address + break; + case 2: + if (_parentAddress == null) { + debugPrint("SerialManager: Error: _parentAddress is null for Level 2 command. Cannot send. Ending cycle."); + _isReading = false; // Release the reading lock + // Call disconnect (now async void) without await here as we don't need to block + disconnect(); // Disconnect due to invalid state + return; + } + commandBody = '${_parentAddress}02000000200000190000'; // Command for Level 2, includes parent address + break; + default: + debugPrint("SerialManager: Attempted to send command for unknown level: $level. Resetting cycle."); + _isReading = false; + return; + } + + String commandHex = '7E02$seqNo$commandBody'; // Full command without CRC + Uint8List commandBytes = Converter.hexStringToByteArray(commandHex); // Convert command hex to bytes + String crc = Crc16Ccitt.computeCrc16Ccitt(commandBytes); // Compute CRC for the command bytes + String finalPacket = commandHex + crc; // Append CRC to the command + + try { + await _port?.write(Converter.hexStringToByteArray(finalPacket)); // Send the full packet over serial + debugPrint("SerialManager: Sent (Lvl: $level): $finalPacket (Expecting lookup: $_lookupString)"); + + // Set a timeout. If no valid response is received within this duration, + // it's assumed the command failed, and the reading cycle is reset. + _cancelTimeout(); // Cancel any previous timeout + _responseTimeoutTimer = Timer(const Duration(seconds: 3), () { + debugPrint("SerialManager: Response timeout for Level $level. Unlocking for next cycle."); + _isReading = false; // Allow the next read cycle to start + _responseBuffer.clear(); // Clear buffer on timeout to avoid stale or partial data confusing future reads + // Optionally, you might want to trigger a retry for the current level here + // or just let the auto-reading timer trigger the next full cycle. + }); + } catch (e) { + debugPrint("SerialManager: Error sending command (Lvl: $level): $e. Disconnecting."); + _isReading = false; // Release the reading lock on send error + // Call disconnect (now async void) without await here as we don't need to block + disconnect(); // Disconnect if sending fails + } + } + + /// Cancels the current response timeout timer if it's active. + void _cancelTimeout() { + _responseTimeoutTimer?.cancel(); + _responseTimeoutTimer = null; + } + + /// Cleans up all resources held by the SerialManager when it's no longer needed. + void dispose() { + debugPrint("SerialManager: Disposing."); + // No need to await disconnect here, as dispose shouldn't necessarily block + // on a full disconnection completion before proceeding with other disposals. + // However, if you *must* ensure full disconnection before other disposals, + // you could add `await disconnect();`. For now, a fire-and-forget call is fine. + disconnect(); // Ensure full disconnection and cleanup + _dataStreamController.close(); // Close the data stream controller + connectionState.dispose(); // Dispose the ValueNotifier + connectedDeviceName.dispose(); // Dispose the ValueNotifier + sondeId.dispose(); // Dispose the Sonde ID ValueNotifier + } +} \ No newline at end of file diff --git a/lib/serial/utils/converter.dart b/lib/serial/utils/converter.dart new file mode 100644 index 0000000..bfce790 --- /dev/null +++ b/lib/serial/utils/converter.dart @@ -0,0 +1,34 @@ +// utils/converter.dart +import 'dart:typed_data'; + +class Converter { + static Uint8List hexStringToByteArray(String hex) { + hex = hex.replaceAll(" ", "").toUpperCase(); + if (hex.length % 2 != 0) hex = '0' + hex; + return Uint8List.fromList( + List.generate(hex.length ~/ 2, (i) => int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16)), + ); + } + + static String byteArrayToHexString(Uint8List bytes) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + } + + static double hexToFloat(String hexString) { + if (hexString.length != 8) throw ArgumentError("Hex string must be 8 characters for a 32-bit float."); + final int intValue = int.parse(hexString, radix: 16); + final ByteData byteData = ByteData(4); + byteData.setUint32(0, intValue, Endian.big); + return byteData.getFloat32(0, Endian.big); + } + + static String hexToAscii(String hexString) { + // Matches Converter.hexToAscii from Converter.java + StringBuffer output = StringBuffer(); + for (int i = 0; i < hexString.length; i += 2) { + String str = hexString.substring(i, i + 2); + output.write(String.fromCharCode(int.parse(str, radix: 16))); + } + return output.toString(); + } +} \ No newline at end of file diff --git a/lib/serial/utils/crc_calculator.dart b/lib/serial/utils/crc_calculator.dart new file mode 100644 index 0000000..85dfe9a --- /dev/null +++ b/lib/serial/utils/crc_calculator.dart @@ -0,0 +1,19 @@ +// utils/crc_calculator.dart +import 'dart:typed_data'; + +class Crc16Ccitt { + static String computeCrc16Ccitt(Uint8List bytes) { + int crc = 0xFFFF; // Initial value + int polynomial = 0x1021; // Matches Java's 0x1021 + for (int b in bytes) { + for (int i = 0; i < 8; i++) { + bool bit = ((b >> (7 - i) & 1) == 1); + bool c15 = ((crc >> 15 & 1) == 1); + crc <<= 1; + if (c15 ^ bit) crc ^= polynomial; + } + } + crc &= 0xffff; + return crc.toRadixString(16).padLeft(4, '0').toUpperCase(); + } +} \ No newline at end of file diff --git a/lib/serial/utils/parameter_helper.dart b/lib/serial/utils/parameter_helper.dart new file mode 100644 index 0000000..d906aaf --- /dev/null +++ b/lib/serial/utils/parameter_helper.dart @@ -0,0 +1,185 @@ +/// A utility class that translates YSI instrument hex codes into human-readable descriptions. +/// This replaces the `ParameterHelper.java` class using a more efficient Map data structure. +class ParameterHelper { + /// A private, static, and constant map for maximum performance. + /// The map is created only once when the app starts. + static const Map _parameterMap = { + '000100010001': 'Combo Probe for PH and ORP, original HW', + '000100010002': 'PH only, Original HW', + '000100010003': 'ammonium', + '000100010004': 'Potasium', + '000100010005': 'Nitrate', + '000100010006': 'Chloride', + '000100010007': 'Combo Probe for PH and ORP, hybrid HW for PH drift', + '000100010008': 'PH only, hybrid HW for PH drift', + '000100010009': 'Combo Probe for PH and ORP, VREF offset HW for PH drift', + '00010001000A': 'PH only, VREF offset HW for PH drift', + '00010001000B': 'Combo Probe for PH and ORP, Floating Reference is at 1.5V', + '00010001000C': 'PH only, Floating Reference is at 1.5V', + '000100020001': 'Probe for ODO', + '000100030001': 'Combo Probe for Conductivity and Temperature', + '000100030002': 'Combo Probe for Wet Cal Conductivity and Temperature', + '000100030003': 'Combo Probe for High accuracy Conductivity and Temperature', + '000100030004': 'Combo Probe for Conductivity and Temperature, Wiped', + '00010003FFFF': 'Mfg tester for Conductivity for CT probe', + '000100040001': 'Probe for Turbidity', + '000100050001': 'EXO1 - 4 port Sonde', + '000100050002': 'EXO2 - 6 port Sonde', + '000100050003': 'EXO3 - 8 port Sonde', + '000100060001': 'Combo of Chlorophyll and BGA-PC', + '000100060002': 'Combo of Chlorophyll and BGA-PE', + '000100070001': 'depth probe, Non-Vented, Standard 0-250m', + '000100070002': 'depth probe, Non-Vented, Medium 0-100m', + '000100070003': 'depth probe, Non-Vented, Shallow 0-10m', + '000100070004': 'depth probe, Vented, 0-10m', + '000100080001': 'Wiper probe', + '000100090001': 'Signal Output Adapter - DCP', + '0001000A0001': 'Original Handheld (used primarily during manufacturing Testing)', + '0001000A0002': 'New Handheld (ProDSS Style) with GPS', + '0001000A0003': 'New Handheld (ProDSS Style) w/out GPS', + '0001000B0001': 'fDOM', + '0001000C0001': 'metal', + '0001000E0001': 'EXO Test Board (reserved for R&D)', + '0001000E0002': 'High Accuracy CT Test board', + '0001000E0003': 'Phloptode', + '0001000F0001': 'Deep UV A Channel T Channel wastewater sensor', + '0001000F0002': 'Deep UV NOx Nitrate+Nitrite sensor', + '000100100001': 'Signal Output Adapter - DCP Gen 2', + '000100100002': 'Signal Output Adapter - DCP Gen 2 - Modbus', + '000100110001': 'Used on both EXO and DSS platforms.', + '000200010001': 'Variable Flourometer Sensor', + '000300010001': 'Combo ODO/Conductivity/Temperature', + '0000': 'Common Message', + '0001': 'Sonde', + '0002': 'PH', + '0003': 'PH: PH units', + '0004': 'PH: mV', + '0005': 'ORP', + '0006': 'ORP: mV', + '0007': 'ORP: mV UnAdjusted', + '0008': 'Optical Dissolved Oxygen', + '0009': 'Optical Dissolved Oxygen: Compensated % Saturation', + '000A': 'Optical Dissolved Oxygen: Compensated mg/L', + '000B': 'Internal uP Temp', + '000C': 'Internal uP Temp: Degrees Celcius', + '000D': 'External Temp', + '000E': 'External Temp: Degrees Celcius', + '000F': 'Barometer', + '0010': 'Barometer: mmHg', + '0011': 'Conductivity', + '0012': 'Conductivity: us/cm', + '0013': 'Conductivity: Salinity', + '0014': 'Turbidity', + '0015': 'Turbidity: FNU', + '0016': 'Turbidity: TSS', + '0017': 'Turbidity: RAW', + '0018': 'Conductivity: Specific Conductivity', + '0019': 'Chlorophyll', + '001A': 'Chlorophyll: ug/L', + '001B': 'BGA-PE', + '001C': 'BGA-PE: cells/mL - replaced with ug/L (0x003B)', + '001D': 'BGA-PC', + '001E': 'BGA-PC: cells/mL - replaced with ug/L (0x003A)', + '001F': 'Chlorophyll: RFU', + '0020': 'BGA-PE: RFU', + '0021': 'BGA-PC: RFU', + '0022': 'Pressure:', + '0023': 'Pressure: psi a', + '0024': 'Pressure: depth', + '0025': 'Etime', + '0026': 'Wiper:', + '0027': 'Wiper: Position Volts', + '0028': 'Sonde: Date and Time', + '0029': 'Sonde: Battery Voltage', + '002A': 'Sonde: External Voltage', + '002B': 'Sonde: Internal Pressure Voltage', + '002C': 'fDOM:', + '002D': 'fDOM: RAW', + '002E': 'fDOM: RFU', + '002F': 'Chlorophyll: RAW', + '0030': 'BGA-PE: RAW', + '0031': 'BGA-PC: RAW', + '0032': 'Wiper: Current(mA)', + '0033': 'Wiper: Profile Time(s)', + '0034': 'Pressure: RAW', + '0035': 'ODO: RAW %Sat', + '0036': 'ODO: RAW mg/L', + '0037': 'fDOM: QSU', + '0038': 'Wiper: Peak Current (mA)', + '0039': 'Wiper: Parking Retries', + '003A': 'BGA-PC: ug/L', + '003B': 'BGA-PE: ug/L', + '003C': 'GPS Latitude', + '003D': 'GPS Longitude', + '003E': 'Conductivity: RAW us/cm', + '003F': 'Pressure: psi g', + '0040': 'Reference Temp', + '0041': 'ODO: % EU', + '0042': 'Ammonium', + '0043': 'Ammonium (NH4+) mV', + '0044': 'Ammonium (NH4+) mg/L', + '0048': 'Nitrate', + '0049': 'Nitrate (NO-3) mV', + '004A': 'Nitrate (NO-3) mg/L', + '004B': 'Chloride', + '004C': 'Chloride (Cl-) mV', + '004D': 'Chloride (Cl-) mg/L', + '004E': 'Ammonia (NH3)', + '004F': 'ODO: % Local', + '0050': 'Conductivity: nLF us/cm', + '0051': 'Sonde: Sensor Power mW', + '0052': 'Sonde: RTC Battery Voltage', + '0053': 'Variable Flourescent (VF) meter', + '0054': 'VF: Units TBD', + '0055': 'Conductivity:TDS mg/L', + '0056': 'Pressure: vert pos', + '0057': 'GPS Date', + '0058': 'GPS Time', + '0059': 'GPS Altitude', + '005A': 'GPS Speed Over Ground (SOG)', + '005B': 'GPS Course Over Ground (COG)', + '005C': 'Barometer Temperature', + '005D': 'Conductivity Resistance (ohms)', + '005E': 'ODO ppm', + '005F': 'Turbidity NTU', + '0060': 'Temprorary Calibration Value', + '0061': 'Sigma', + '0062': 'Sigma-T', + '0063': 'Deep UV Wastewater: Channel A', + '0064': 'Deep UV Wastewater: Channel A Raw', + '0065': 'Deep UV Wastewater: Channel A RFU', + '0066': 'Deep UV Wastewater: Channel A Concentration', + '0067': 'Deep UV Wastewater: Channel T', + '0068': 'Deep UV Wastewater: Channel T Raw', + '0069': 'Deep UV Wastewater: Channel T RFU', + '006A': 'Deep UV Wastewater: Channel T Concentration', + '006B': 'Deep UV Wastewater: Ratio', + '006C': 'Deep UV NOx Ch238', + '006D': 'Deep UV NOx Ch238 Raw', + '006E': 'Deep UV NOx Ch238Ref', + '006F': 'Deep UV NOx Ch238Ref Raw', + '0070': 'Deep UV NOx Ch238 Absorbance', + '0071': 'Deep UV NOx Ch238 Absorbance Turb Corrected', + '0072': 'Deep UV NOx Ch275', + '0073': 'Deep UV NOx Ch275 Raw', + '0074': 'Deep UV NOx Ch275Ref', + '0075': 'Deep UV NOx Ch275Ref Raw', + '0076': 'Deep UV NOx Ch275 Absorbance', + '0077': 'Deep UV NOx Ch275 Absorbance Turb Corrected', + '0078': 'Deep UV NOx', + '0079': 'Deep UV NOx RAW', + '007A': 'Deep UV NOx mg/L', + '007B': 'fDOM ppb', + '9900': 'SOA/DCP', + 'FF00': 'Handheld', + 'FF01': 'Hipster', + 'FF11': 'Conductivity Manufacturing Tester', + }; + + /// Takes a hex code string and returns its human-readable description. + /// If the code is not found, it returns 'UNKNOWN'. + static String getDescription(String hexCode) { + // The '??' operator provides a default value if the key is not in the map. + return _parameterMap[hexCode.toUpperCase()] ?? 'UNKNOWN'; + } +} \ No newline at end of file diff --git a/lib/serial/widget/serial_port_list_dialog.dart b/lib/serial/widget/serial_port_list_dialog.dart new file mode 100644 index 0000000..1ae08d2 --- /dev/null +++ b/lib/serial/widget/serial_port_list_dialog.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:usb_serial/usb_serial.dart'; + +Future showSerialPortListDialog({ + required BuildContext context, + required List devices, + String title = 'Select a Serial Device', +}) { + return showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(title), + children: devices.map((device) { + return SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(device), + child: ListTile( + leading: Icon(Icons.usb), + title: Text(device.productName ?? 'Unknown Device'), + subtitle: Text('VID: ${device.vid}, PID: ${device.pid}'), + ), + ); + }).toList(), + ); + }, + ); +} \ No newline at end of file diff --git a/lib/services/air_api_service.dart b/lib/services/air_api_service.dart new file mode 100644 index 0000000..ef8d49e --- /dev/null +++ b/lib/services/air_api_service.dart @@ -0,0 +1,13 @@ +// lib/services/air_api_service.dart + +import 'package:environment_monitoring_app/services/base_api_service.dart'; + +class AirApiService { + final BaseApiService _baseService = BaseApiService(); + +// You can add methods for air-related API calls here in the future +// For example: +// Future> getAirQualityData() { +// return _baseService.get('air/dashboard'); +// } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart new file mode 100644 index 0000000..9f13faf --- /dev/null +++ b/lib/services/api_service.dart @@ -0,0 +1,346 @@ +// lib/services/api_service.dart + +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:environment_monitoring_app/services/base_api_service.dart'; + +// ======================================================================= +// Part 1: Unified API Service +// ======================================================================= + +/// A unified service that consolidates all API interactions for the application. +/// It is organized by feature (e.g., marine, river) for clarity and provides +/// a central point for data synchronization. +class ApiService { + final BaseApiService _baseService = BaseApiService(); + final DatabaseHelper _dbHelper = DatabaseHelper(); + + late final MarineApiService marine; + late final RiverApiService river; + + static const String imageBaseUrl = 'https://dev14.pstw.com.my/'; + + ApiService() { + marine = MarineApiService(_baseService); + river = RiverApiService(_baseService); + } + + // --- Core API Methods --- + + Future> login(String email, String password) { + return _baseService.post('auth/login', {'email': email, 'password': password}); + } + + Future> register({ + required String username, + String? firstName, + String? lastName, + required String email, + required String password, + String? phoneNumber, + int? departmentId, + int? companyId, + int? positionId, + }) { + final Map body = { + 'username': username, + 'email': email, + 'password': password, + 'first_name': firstName ?? '', + 'last_name': lastName ?? '', + 'phone_number': phoneNumber ?? '', + 'department_id': departmentId, + 'company_id': companyId, + 'position_id': positionId, + }; + body.removeWhere((key, value) => value == null); + return _baseService.post('auth/register', body); + } + + Future> post(String endpoint, Map data) { + return _baseService.post(endpoint, data); + } + + Future> getProfile() => _baseService.get('profile'); + Future> getAllUsers() => _baseService.get('users'); + + Future> getAllDepartments() => _baseService.get('departments'); + Future> getAllCompanies() => _baseService.get('companies'); + Future> getAllPositions() => _baseService.get('positions'); + + // --- ADDED: Method to send a queued Telegram alert to your server --- + Future> sendTelegramAlert({ + required String chatId, + required String message, + }) { + return _baseService.post('marine/telegram-alert', { + 'chat_id': chatId, + 'message': message, + }); + } + // --- END --- + + Future downloadProfilePicture(String imageUrl, String localPath) async { + try { + final response = await http.get(Uri.parse(imageUrl)); + if (response.statusCode == 200) { + final File file = File(localPath); + await file.parent.create(recursive: true); + await file.writeAsBytes(response.bodyBytes); + return file; + } + } catch (e) { + debugPrint('Error downloading profile picture: $e'); + } + return null; + } + + Future> uploadProfilePicture(File imageFile) { + return _baseService.postMultipart( + endpoint: 'profile/upload-picture', + fields: {}, + files: {'profile_picture': imageFile} + ); + } + + /// --- A dedicated method to refresh only the profile --- + Future> refreshProfile() async { + debugPrint('ApiService: Refreshing profile data from server...'); + final result = await getProfile(); + if (result['success'] == true && result['data'] != null) { + await _dbHelper.saveProfile(result['data']); + debugPrint('ApiService: Profile data refreshed and saved to local DB.'); + } + return result; + } + + + /// Orchestrates a full data sync from the server to the local database. + Future> syncAllData() async { + debugPrint('ApiService: Starting full data sync from server...'); + try { + final results = await Future.wait([ + getProfile(), + getAllUsers(), + marine.getTarballStations(), + marine.getManualStations(), + marine.getTarballClassifications(), + river.getManualStations(), + river.getTriennialStations(), + getAllDepartments(), + getAllCompanies(), + getAllPositions(), + ]); + + final Map syncedData = { + 'profile': results[0]['success'] == true ? results[0]['data'] : null, + 'allUsers': results[1]['success'] == true ? results[1]['data'] : null, + 'tarballStations': results[2]['success'] == true ? results[2]['data'] : null, + 'manualStations': results[3]['success'] == true ? results[3]['data'] : null, + 'tarballClassifications': results[4]['success'] == true ? results[4]['data'] : null, + 'riverManualStations': results[5]['success'] == true ? results[5]['data'] : null, + 'riverTriennialStations': results[6]['success'] == true ? results[6]['data'] : null, + 'departments': results[7]['success'] == true ? results[7]['data'] : null, + 'companies': results[8]['success'] == true ? results[8]['data'] : null, + 'positions': results[9]['success'] == true ? results[9]['data'] : null, + }; + + if (syncedData['profile'] != null) await _dbHelper.saveProfile(syncedData['profile']); + if (syncedData['allUsers'] != null) await _dbHelper.saveUsers(List>.from(syncedData['allUsers'])); + if (syncedData['tarballStations'] != null) await _dbHelper.saveTarballStations(List>.from(syncedData['tarballStations'])); + if (syncedData['manualStations'] != null) await _dbHelper.saveManualStations(List>.from(syncedData['manualStations'])); + if (syncedData['tarballClassifications'] != null) await _dbHelper.saveTarballClassifications(List>.from(syncedData['tarballClassifications'])); + if (syncedData['riverManualStations'] != null) await _dbHelper.saveRiverManualStations(List>.from(syncedData['riverManualStations'])); + if (syncedData['riverTriennialStations'] != null) await _dbHelper.saveRiverTriennialStations(List>.from(syncedData['riverTriennialStations'])); + if (syncedData['departments'] != null) await _dbHelper.saveDepartments(List>.from(syncedData['departments'])); + if (syncedData['companies'] != null) await _dbHelper.saveCompanies(List>.from(syncedData['companies'])); + if (syncedData['positions'] != null) await _dbHelper.savePositions(List>.from(syncedData['positions'])); + + debugPrint('ApiService: Sync complete. Data saved to local DB.'); + return {'success': true, 'data': syncedData}; + + } catch (e) { + debugPrint('ApiService: Full data sync failed: $e'); + return {'success': false, 'message': 'Data sync failed: $e'}; + } + } +} + +// ======================================================================= +// Part 2: Feature-Specific API Services +// ======================================================================= + +class MarineApiService { + final BaseApiService _baseService; + MarineApiService(this._baseService); + + Future> getTarballStations() => _baseService.get('marine/tarball/stations'); + Future> getManualStations() => _baseService.get('marine/manual/stations'); + Future> getTarballClassifications() => _baseService.get('marine/tarball/classifications'); + + Future> submitTarballSample({ + required Map formData, + required Map imageFiles, + }) async { + final dataResult = await _baseService.post('marine/tarball/sample', formData); + if (dataResult['success'] != true) return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'}; + + final recordId = dataResult['data']?['autoid']; + if (recordId == null) return {'status': 'L2', 'success': false, 'message': 'Data submitted, but failed to get a record ID.'}; + + final filesToUpload = {}; + imageFiles.forEach((key, value) { if (value != null) filesToUpload[key] = value; }); + + if (filesToUpload.isEmpty) return {'status': 'L3', 'success': true, 'message': 'Data submitted successfully.', 'reportId': recordId}; + + final imageResult = await _baseService.postMultipart(endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload); + if (imageResult['success'] != true) return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId}; + + return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId}; + } +} + +class RiverApiService { + final BaseApiService _baseService; + RiverApiService(this._baseService); + + Future> getManualStations() => _baseService.get('river/manual-stations'); + Future> getTriennialStations() => _baseService.get('river/triennial-stations'); +} + +// ======================================================================= +// Part 3: Local Database Helper +// ======================================================================= + +class DatabaseHelper { + static Database? _database; + static const String _dbName = 'app_data.db'; + // FIXED: Incremented database version to trigger onUpgrade and create the new table + static const int _dbVersion = 10; + + static const String _profileTable = 'user_profile'; + static const String _usersTable = 'all_users'; + static const String _tarballStationsTable = 'marine_tarball_stations'; + static const String _manualStationsTable = 'marine_manual_stations'; + static const String _riverManualStationsTable = 'river_manual_stations'; + static const String _riverTriennialStationsTable = 'river_triennial_stations'; + static const String _tarballClassificationsTable = 'marine_tarball_classifications'; + static const String _departmentsTable = 'departments'; + static const String _companiesTable = 'companies'; + static const String _positionsTable = 'positions'; + // --- ADDED: Table name for the alert queue --- + static const String _alertQueueTable = 'alert_queue'; + + Future get database async { + if (_database != null) return _database!; + _database = await _initDB(); + return _database!; + } + + Future _initDB() async { + String dbPath = p.join(await getDatabasesPath(), _dbName); + return await openDatabase(dbPath, version: _dbVersion, onCreate: _onCreate, onUpgrade: _onUpgrade); + } + + Future _onCreate(Database db, int version) async { + await db.execute('CREATE TABLE $_profileTable(user_id INTEGER PRIMARY KEY, profile_json TEXT)'); + await db.execute('CREATE TABLE $_usersTable(user_id INTEGER PRIMARY KEY, user_json TEXT)'); + await db.execute('CREATE TABLE $_tarballStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE $_manualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE $_riverManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE $_riverTriennialStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE $_tarballClassificationsTable(classification_id INTEGER PRIMARY KEY, classification_json TEXT)'); + await db.execute('CREATE TABLE $_departmentsTable(department_id INTEGER PRIMARY KEY, department_json TEXT)'); + await db.execute('CREATE TABLE $_companiesTable(company_id INTEGER PRIMARY KEY, company_json TEXT)'); + await db.execute('CREATE TABLE $_positionsTable(position_id INTEGER PRIMARY KEY, position_json TEXT)'); + + // --- ADDED: Create the alert_queue table --- + await db.execute(''' + CREATE TABLE $_alertQueueTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id TEXT NOT NULL, + message TEXT NOT NULL, + created_at TEXT NOT NULL + ) + '''); + } + + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 10) { + await db.execute('DROP TABLE IF EXISTS $_profileTable'); + await db.execute('DROP TABLE IF EXISTS $_usersTable'); + await db.execute('DROP TABLE IF EXISTS $_tarballStationsTable'); + await db.execute('DROP TABLE IF EXISTS $_manualStationsTable'); + await db.execute('DROP TABLE IF EXISTS $_riverManualStationsTable'); + await db.execute('DROP TABLE IF EXISTS $_riverTriennialStationsTable'); + await db.execute('DROP TABLE IF EXISTS $_tarballClassificationsTable'); + await db.execute('DROP TABLE IF EXISTS $_departmentsTable'); + await db.execute('DROP TABLE IF EXISTS $_companiesTable'); + await db.execute('DROP TABLE IF EXISTS $_positionsTable'); + // --- ADDED: Drop the alert_queue table during upgrade --- + await db.execute('DROP TABLE IF EXISTS $_alertQueueTable'); + await _onCreate(db, newVersion); + } + } + + Future _saveData(String table, String idKey, List> data) async { + final db = await database; + await db.delete(table); + for (var item in data) { + await db.insert(table, {'${idKey}_id': item[idKey], '${idKey}_json': jsonEncode(item)}, conflictAlgorithm: ConflictAlgorithm.replace); + } + } + + Future>?> _loadData(String table, String idKey) async { + final db = await database; + final List> maps = await db.query(table); + if (maps.isNotEmpty) { + return maps.map((map) => jsonDecode(map['${idKey}_json']) as Map).toList(); + } + return null; + } + + Future saveProfile(Map profile) async { + final db = await database; + await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)}, conflictAlgorithm: ConflictAlgorithm.replace); + } + Future?> loadProfile() async { + final db = await database; + final List> maps = await db.query(_profileTable); + if(maps.isNotEmpty) return jsonDecode(maps.first['profile_json']); + return null; + } + + Future saveUsers(List> users) => _saveData(_usersTable, 'user', users); + Future>?> loadUsers() => _loadData(_usersTable, 'user'); + + Future saveTarballStations(List> stations) => _saveData(_tarballStationsTable, 'station', stations); + Future>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station'); + + Future saveManualStations(List> stations) => _saveData(_manualStationsTable, 'station', stations); + Future>?> loadManualStations() => _loadData(_manualStationsTable, 'station'); + + Future saveRiverManualStations(List> stations) => _saveData(_riverManualStationsTable, 'station', stations); + Future>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station'); + + Future saveRiverTriennialStations(List> stations) => _saveData(_riverTriennialStationsTable, 'station', stations); + Future>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station'); + + Future saveTarballClassifications(List> data) => _saveData(_tarballClassificationsTable, 'classification', data); + Future>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification'); + + Future saveDepartments(List> data) => _saveData(_departmentsTable, 'department', data); + Future>?> loadDepartments() => _loadData(_departmentsTable, 'department'); + + Future saveCompanies(List> data) => _saveData(_companiesTable, 'company', data); + Future>?> loadCompanies() => _loadData(_companiesTable, 'company'); + + Future savePositions(List> data) => _saveData(_positionsTable, 'position', data); + Future>?> loadPositions() => _loadData(_positionsTable, 'position'); +} \ No newline at end of file diff --git a/lib/services/base_api_service.dart b/lib/services/base_api_service.dart new file mode 100644 index 0000000..beb2827 --- /dev/null +++ b/lib/services/base_api_service.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path/path.dart' as path; +import 'package:environment_monitoring_app/auth_provider.dart'; + +class BaseApiService { + final String _baseUrl = 'https://dev14.pstw.com.my/v1'; + + // Private helper to construct headers with the auth token + Future> _getHeaders() async { + final prefs = await SharedPreferences.getInstance(); + final String? token = prefs.getString(AuthProvider.tokenKey); + // For multipart requests, 'Content-Type' is set by the http client. + // We only need Authorization here. + return { + if (token != null) 'Authorization': 'Bearer $token', + }; + } + + // Private helper for JSON headers + Future> _getJsonHeaders() async { + final headers = await _getHeaders(); + headers['Content-Type'] = 'application/json'; + return headers; + } + + // Generic GET request handler + Future> get(String endpoint) async { + final url = Uri.parse('$_baseUrl/$endpoint'); + try { + final response = await http.get(url, headers: await _getJsonHeaders()); + return _handleResponse(response); + } catch (e) { + debugPrint('GET request failed: $e'); + return {'success': false, 'message': 'Network error: $e'}; + } + } + + // Generic POST request handler for JSON data + Future> post(String endpoint, Map body) async { + final url = Uri.parse('$_baseUrl/$endpoint'); + try { + final response = await http.post( + url, + headers: await _getJsonHeaders(), + body: jsonEncode(body), + ); + return _handleResponse(response); + } catch (e) { + debugPrint('POST request failed: $e'); + return {'success': false, 'message': 'Network error: $e'}; + } + } + + // Generic handler for multipart (file upload) requests + Future> postMultipart({ + required String endpoint, + required Map fields, + required Map files, + }) async { + final url = Uri.parse('$_baseUrl/$endpoint'); + debugPrint('Starting multipart upload to: $url'); + + try { + var request = http.MultipartRequest('POST', url); + + // Get and add headers (Authorization token) + final headers = await _getHeaders(); + request.headers.addAll(headers); + debugPrint('Headers added to multipart request.'); + + // CORRECTED: Send all text fields as a single JSON string under the key 'data'. + // This is a common pattern for APIs that handle mixed file/data uploads and + // helps prevent issues where servers fail to parse individual fields. + if (fields.isNotEmpty) { + request.fields['data'] = jsonEncode(fields); + debugPrint('Fields added as a single JSON string under the key "data".'); + } + + // Add files + for (var entry in files.entries) { + debugPrint('Adding file: ${entry.key}, path: ${entry.value.path}'); + request.files.add(await http.MultipartFile.fromPath( + entry.key, + entry.value.path, + filename: path.basename(entry.value.path), + )); + } + debugPrint('${files.length} files added to the request.'); + + debugPrint('Sending multipart request...'); + var streamedResponse = await request.send(); + debugPrint('Received response with status code: ${streamedResponse.statusCode}'); + + final responseBody = await streamedResponse.stream.bytesToString(); + // We create a standard http.Response to use our standard handler + return _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); + + } catch (e, s) { // Catching both Exception and Error (e.g., OutOfMemoryError) + debugPrint('FATAL: An error occurred during multipart upload: $e'); + debugPrint('Stack trace: $s'); + return { + 'success': false, + 'message': 'Upload failed due to a critical error. This might be caused by low device memory or a network issue.' + }; + } + } + + // Centralized response handling + Map _handleResponse(http.Response response) { + debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}'); + try { + final Map responseData = jsonDecode(response.body); + if (response.statusCode >= 200 && response.statusCode < 300) { + // Assuming the API returns a 'status' field in the JSON body + if (responseData['status'] == 'success' || responseData['success'] == true) { + return {'success': true, 'data': responseData['data'], 'message': responseData['message']}; + } else { + return {'success': false, 'message': responseData['message'] ?? 'An unknown API error occurred.'}; + } + } else { + // Handle server errors (4xx, 5xx) + return {'success': false, 'message': responseData['message'] ?? 'Server error: ${response.statusCode}'}; + } + } catch (e) { + // Handle cases where the response body is not valid JSON + debugPrint('Failed to parse server response: $e'); + return {'success': false, 'message': 'Failed to parse server response. Body: ${response.body}'}; + } + } +} diff --git a/lib/services/in_situ_sampling_service.dart b/lib/services/in_situ_sampling_service.dart new file mode 100644 index 0000000..6dd04a0 --- /dev/null +++ b/lib/services/in_situ_sampling_service.dart @@ -0,0 +1,153 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:image/image.dart' as img; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; +import 'package:usb_serial/usb_serial.dart'; + +import 'location_service.dart'; +import 'marine_api_service.dart'; +import '../models/in_situ_sampling_data.dart'; +import '../bluetooth/bluetooth_manager.dart'; +import '../serial/serial_manager.dart'; + +/// A dedicated service to handle all business logic for the In-Situ Sampling feature. +/// This includes location, image processing, device communication, and data submission. +class InSituSamplingService { + final LocationService _locationService = LocationService(); + final MarineApiService _marineApiService = MarineApiService(); + final BluetoothManager _bluetoothManager = BluetoothManager(); + final SerialManager _serialManager = SerialManager(); + + // This channel name MUST match the one defined in MainActivity.kt + static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); + + + // --- Location Services --- + Future getCurrentLocation() => _locationService.getCurrentLocation(); + double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2); + + // --- Image Processing --- + Future pickAndProcessImage(ImageSource source, { + required InSituSamplingData data, + required String imageInfo, + bool isRequired = false, + }) async { + final picker = ImagePicker(); + final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); + if (photo == null) return null; + + final bytes = await photo.readAsBytes(); + img.Image? originalImage = img.decodeImage(bytes); + if (originalImage == null) return null; + + if (isRequired && originalImage.height > originalImage.width) { + debugPrint("Image rejected: Must be in landscape orientation."); + return null; + } + + final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}"; + final font = img.arial24; + final textWidth = watermarkTimestamp.length * 12; + img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255)); + img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0)); + + final tempDir = await getTemporaryDirectory(); + final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; + final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); + final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; + final filePath = path.join(tempDir.path, newFileName); + + return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); + } + + // --- Device Connection (Delegated to Managers) --- + ValueNotifier get bluetoothConnectionState => _bluetoothManager.connectionState; + ValueNotifier get serialConnectionState => _serialManager.connectionState; + + // REPAIRED: This getter now dynamically returns the correct Sonde ID notifier + // based on the active connection, which is essential for the UI. + ValueNotifier get sondeId { + if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) { + return _bluetoothManager.sondeId; + } + return _serialManager.sondeId; + } + + Stream> get bluetoothDataStream => _bluetoothManager.dataStream; + Stream> get serialDataStream => _serialManager.dataStream; + + // REPAIRED: Added .value to both getters for consistency and to prevent errors. + String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value; + String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value; + + // --- Permissions --- + Future requestDevicePermissions() async { + Map statuses = await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.locationWhenInUse, + ].request(); + + // Return true only if the essential permissions are granted. + if (statuses[Permission.bluetoothScan] == PermissionStatus.granted && + statuses[Permission.bluetoothConnect] == PermissionStatus.granted) { + return true; + } else { + return false; + } + } + + // --- Bluetooth Methods --- + Future> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices(); + Future connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device); + void disconnectFromBluetooth() => _bluetoothManager.disconnect(); + void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); + void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading(); + + + // --- USB Serial Methods --- + Future> getAvailableSerialDevices() => _serialManager.getAvailableDevices(); + + Future requestUsbPermission(UsbDevice device) async { + try { + return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false; + } on PlatformException catch (e) { + debugPrint("Failed to request USB permission: '${e.message}'."); + return false; + } + } + + Future connectToSerialDevice(UsbDevice device) async { + final bool permissionGranted = await requestUsbPermission(device); + if (permissionGranted) { + await _serialManager.connect(device); + } else { + throw Exception("USB permission was not granted."); + } + } + + void disconnectFromSerial() => _serialManager.disconnect(); + void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); + void stopSerialAutoReading() => _serialManager.stopAutoReading(); + + + void dispose() { + _bluetoothManager.dispose(); + _serialManager.dispose(); + } + + // --- Data Submission --- + Future> submitData(InSituSamplingData data) { + return _marineApiService.submitInSituSample( + formData: data.toApiFormData(), + imageFiles: data.toApiImageFiles(), + ); + } +} \ No newline at end of file diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart new file mode 100644 index 0000000..f50571f --- /dev/null +++ b/lib/services/local_storage_service.dart @@ -0,0 +1,368 @@ +// lib/services/local_storage_service.dart + +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:path/path.dart' as p; + +import '../models/tarball_data.dart'; +import '../models/in_situ_sampling_data.dart'; +// ADDED: Import the river-specific data model +import '../models/river_in_situ_sampling_data.dart'; + +/// A comprehensive service for handling all local data storage for offline submissions. +class LocalStorageService { + + // ======================================================================= + // Part 1: Public Storage Setup + // ======================================================================= + + /// Checks for and requests necessary storage permissions for public storage. + Future _requestPermissions() async { + var status = await Permission.manageExternalStorage.request(); + return status.isGranted; + } + + /// Gets the public external storage directory and creates the base MMSV4 folder. + Future _getPublicMMSV4Directory() async { + if (await _requestPermissions()) { + final Directory? externalDir = await getExternalStorageDirectory(); + if (externalDir != null) { + // Navigates up from the app-specific folder to the public root + final publicRootPath = externalDir.path.split('/Android/')[0]; + final mmsv4Dir = Directory(p.join(publicRootPath, 'MMSV4')); + if (!await mmsv4Dir.exists()) { + await mmsv4Dir.create(recursive: true); + } + return mmsv4Dir; + } + } + debugPrint("LocalStorageService: Manage External Storage permission was not granted."); + return null; + } + + // ======================================================================= + // Part 2: Tarball Specific Methods + // ======================================================================= + + /// Gets the base directory for storing tarball sampling data logs. + Future _getTarballBaseDir() async { + final mmsv4Dir = await _getPublicMMSV4Directory(); + if (mmsv4Dir == null) return null; + + final tarballDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_tarball_sampling')); + if (!await tarballDir.exists()) { + await tarballDir.create(recursive: true); + } + return tarballDir; + } + + /// Saves a single tarball sampling record to a unique folder in public storage. + Future saveTarballSamplingData(TarballSamplingData data) async { + final baseDir = await _getTarballBaseDir(); + if (baseDir == null) { + debugPrint("Could not get public storage directory. Check permissions."); + return null; + } + + try { + final stationCode = data.selectedStation?['tbl_station_code'] ?? 'UNKNOWN_STATION'; + final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}"; + final eventFolderName = "${stationCode}_$timestamp"; + final eventDir = Directory(p.join(baseDir.path, eventFolderName)); + + if (!await eventDir.exists()) { + await eventDir.create(recursive: true); + } + + final Map jsonData = { ...data.toFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; + + jsonData['selectedStation'] = data.selectedStation; + + final imageFiles = data.toImageFiles(); + + for (var entry in imageFiles.entries) { + final File? imageFile = entry.value; + if (imageFile != null) { + final String originalFileName = p.basename(imageFile.path); + final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); + jsonData[entry.key] = newFile.path; + } + } + + final jsonFile = File(p.join(eventDir.path, 'data.json')); + await jsonFile.writeAsString(jsonEncode(jsonData)); + debugPrint("Tarball log saved to: ${jsonFile.path}"); + + return eventDir.path; + + } catch (e) { + debugPrint("Error saving tarball log to local storage: $e"); + return null; + } + } + + /// Retrieves all saved tarball submission logs from public storage. + Future>> getAllTarballLogs() async { + final baseDir = await _getTarballBaseDir(); + if (baseDir == null || !await baseDir.exists()) return []; + + try { + final List> logs = []; + final entities = baseDir.listSync(); + + for (var entity in entities) { + if (entity is Directory) { + final jsonFile = File(p.join(entity.path, 'data.json')); + if (await jsonFile.exists()) { + final content = await jsonFile.readAsString(); + final data = jsonDecode(content) as Map; + data['logDirectory'] = entity.path; // Add directory path for resubmission/update + logs.add(data); + } + } + } + return logs; + } catch (e) { + debugPrint("Error getting all tarball logs: $e"); + return []; + } + } + + /// Updates an existing log file with new submission status. + Future updateTarballLog(Map updatedLogData) async { + final logDir = updatedLogData['logDirectory']; + if (logDir == null) { + debugPrint("Cannot update log: logDirectory key is missing."); + return; + } + + try { + final jsonFile = File(p.join(logDir, 'data.json')); + if (await jsonFile.exists()) { + updatedLogData.remove('isResubmitting'); + await jsonFile.writeAsString(jsonEncode(updatedLogData)); + debugPrint("Log updated successfully at: ${jsonFile.path}"); + } + } catch (e) { + debugPrint("Error updating tarball log: $e"); + } + } + + + // ======================================================================= + // Part 3: Marine In-Situ Specific Methods + // ======================================================================= + + /// Gets the base directory for storing marine in-situ sampling data logs. + Future _getInSituBaseDir() async { + final mmsv4Dir = await _getPublicMMSV4Directory(); + if (mmsv4Dir == null) return null; + + final inSituDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_in_situ_sampling')); + if (!await inSituDir.exists()) { + await inSituDir.create(recursive: true); + } + return inSituDir; + } + + /// Saves a single marine in-situ sampling record to a unique folder in public storage. + Future saveInSituSamplingData(InSituSamplingData data) async { + final baseDir = await _getInSituBaseDir(); + if (baseDir == null) { + debugPrint("Could not get public storage directory for In-Situ. Check permissions."); + return null; + } + + try { + final stationCode = data.selectedStation?['man_station_code'] ?? 'UNKNOWN_STATION'; + final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}"; + final eventFolderName = "${stationCode}_$timestamp"; + final eventDir = Directory(p.join(baseDir.path, eventFolderName)); + + if (!await eventDir.exists()) { + await eventDir.create(recursive: true); + } + + final Map jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; + + jsonData['selectedStation'] = data.selectedStation; + + final imageFiles = data.toApiImageFiles(); + for (var entry in imageFiles.entries) { + final File? imageFile = entry.value; + if (imageFile != null) { + final String originalFileName = p.basename(imageFile.path); + final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); + jsonData[entry.key] = newFile.path; + } + } + + final jsonFile = File(p.join(eventDir.path, 'data.json')); + await jsonFile.writeAsString(jsonEncode(jsonData)); + debugPrint("In-Situ log saved to: ${jsonFile.path}"); + + return eventDir.path; + + } catch (e) { + debugPrint("Error saving In-Situ log to local storage: $e"); + return null; + } + } + + /// Retrieves all saved marine in-situ submission logs from public storage. + Future>> getAllInSituLogs() async { + final baseDir = await _getInSituBaseDir(); + if (baseDir == null || !await baseDir.exists()) return []; + + try { + final List> logs = []; + final entities = baseDir.listSync(); + + for (var entity in entities) { + if (entity is Directory) { + final jsonFile = File(p.join(entity.path, 'data.json')); + if (await jsonFile.exists()) { + final content = await jsonFile.readAsString(); + final data = jsonDecode(content) as Map; + data['logDirectory'] = entity.path; + logs.add(data); + } + } + } + return logs; + } catch (e) { + debugPrint("Error getting all in-situ logs: $e"); + return []; + } + } + + /// Updates an existing marine in-situ log file with new submission status. + Future updateInSituLog(Map updatedLogData) async { + final logDir = updatedLogData['logDirectory']; + if (logDir == null) { + debugPrint("Cannot update log: logDirectory key is missing."); + return; + } + + try { + final jsonFile = File(p.join(logDir, 'data.json')); + if (await jsonFile.exists()) { + updatedLogData.remove('isResubmitting'); + await jsonFile.writeAsString(jsonEncode(updatedLogData)); + debugPrint("Log updated successfully at: ${jsonFile.path}"); + } + } catch (e) { + debugPrint("Error updating in-situ log: $e"); + } + } + + // ======================================================================= + // ADDED: Part 4: River In-Situ Specific Methods + // ======================================================================= + + /// Gets the base directory for storing river in-situ sampling data logs. + Future _getRiverInSituBaseDir() async { + final mmsv4Dir = await _getPublicMMSV4Directory(); + if (mmsv4Dir == null) return null; + + final inSituDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_in_situ_sampling')); + if (!await inSituDir.exists()) { + await inSituDir.create(recursive: true); + } + return inSituDir; + } + + /// Saves a single river in-situ sampling record to a unique folder in public storage. + Future saveRiverInSituSamplingData(RiverInSituSamplingData data) async { + final baseDir = await _getRiverInSituBaseDir(); + if (baseDir == null) { + debugPrint("Could not get public storage directory for River In-Situ. Check permissions."); + return null; + } + + try { + final stationCode = data.selectedStation?['r_man_station_code'] ?? 'UNKNOWN_STATION'; + final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}"; + final eventFolderName = "${stationCode}_$timestamp"; + final eventDir = Directory(p.join(baseDir.path, eventFolderName)); + + if (!await eventDir.exists()) { + await eventDir.create(recursive: true); + } + + final Map jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; + + jsonData['selectedStation'] = data.selectedStation; + + final imageFiles = data.toApiImageFiles(); + for (var entry in imageFiles.entries) { + final File? imageFile = entry.value; + if (imageFile != null) { + final String originalFileName = p.basename(imageFile.path); + final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); + jsonData[entry.key] = newFile.path; + } + } + + final jsonFile = File(p.join(eventDir.path, 'data.json')); + await jsonFile.writeAsString(jsonEncode(jsonData)); + debugPrint("River In-Situ log saved to: ${jsonFile.path}"); + + return eventDir.path; + + } catch (e) { + debugPrint("Error saving River In-Situ log to local storage: $e"); + return null; + } + } + + /// Retrieves all saved river in-situ submission logs from public storage. + Future>> getAllRiverInSituLogs() async { + final baseDir = await _getRiverInSituBaseDir(); + if (baseDir == null || !await baseDir.exists()) return []; + + try { + final List> logs = []; + final entities = baseDir.listSync(); + + for (var entity in entities) { + if (entity is Directory) { + final jsonFile = File(p.join(entity.path, 'data.json')); + if (await jsonFile.exists()) { + final content = await jsonFile.readAsString(); + final data = jsonDecode(content) as Map; + data['logDirectory'] = entity.path; + logs.add(data); + } + } + } + return logs; + } catch (e) { + debugPrint("Error getting all river in-situ logs: $e"); + return []; + } + } + + /// Updates an existing river in-situ log file with new submission status. + Future updateRiverInSituLog(Map updatedLogData) async { + final logDir = updatedLogData['logDirectory']; + if (logDir == null) { + debugPrint("Cannot update log: logDirectory key is missing."); + return; + } + + try { + final jsonFile = File(p.join(logDir, 'data.json')); + if (await jsonFile.exists()) { + updatedLogData.remove('isResubmitting'); + await jsonFile.writeAsString(jsonEncode(updatedLogData)); + debugPrint("Log updated successfully at: ${jsonFile.path}"); + } + } catch (e) { + debugPrint("Error updating river in-situ log: $e"); + } + } +} \ No newline at end of file diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart new file mode 100644 index 0000000..21dbc19 --- /dev/null +++ b/lib/services/location_service.dart @@ -0,0 +1,71 @@ +import 'dart:math'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// A dedicated service for handling all location-based functionalities. +class LocationService { + + /// Checks for and requests location permissions, then fetches the current GPS position. + /// This includes an offline-first approach by trying to get the last known position. + Future getCurrentLocation() async { + bool serviceEnabled; + LocationPermission permission; + + // Check if location services are enabled on the device. + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return Future.error('Location services are disabled.'); + } + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + return Future.error('Location permissions are denied'); + } + } + + if (permission == LocationPermission.deniedForever) { + return Future.error( + 'Location permissions are permanently denied, we cannot request permissions.'); + } + + // Attempt to get the last known position for a fast, offline-capable response. + Position? position = await Geolocator.getLastKnownPosition(); + + // If no last known position, fetch the current position. + position ??= await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high); + + return position; + } + + /// Calculates the distance in kilometers between two latitude/longitude pairs. + double calculateDistance(double lat1, double lon1, double lat2, double lon2) { + const earthRadius = 6371; // Radius of the Earth in km + + final dLat = _degreesToRadians(lat2 - lat1); + final dLon = _degreesToRadians(lon2 - lon1); + + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(_degreesToRadians(lat1)) * + cos(_degreesToRadians(lat2)) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + + return earthRadius * c; // Distance in km + } + + double _degreesToRadians(double degree) { + return degree * pi / 180; + } + + /// A helper method to request location permissions explicitly if needed. + Future requestPermissions() async { + await [ + Permission.locationWhenInUse, + ].request(); + } +} diff --git a/lib/services/marine_api_service.dart b/lib/services/marine_api_service.dart new file mode 100644 index 0000000..df5d75c --- /dev/null +++ b/lib/services/marine_api_service.dart @@ -0,0 +1,226 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; +import 'package:environment_monitoring_app/services/base_api_service.dart'; +import 'package:environment_monitoring_app/services/telegram_service.dart'; +import 'package:environment_monitoring_app/services/settings_service.dart'; + +class MarineApiService { + final BaseApiService _baseService = BaseApiService(); + final TelegramService _telegramService = TelegramService(); + final SettingsService _settingsService = SettingsService(); + + Future> getTarballStations() { + return _baseService.get('marine/tarball/stations'); + } + + Future> getManualStations() { + return _baseService.get('marine/manual/stations'); + } + + Future> getTarballClassifications() { + return _baseService.get('marine/tarball/classifications'); + } + + /// Orchestrates a two-step submission process for tarball samples. (Unchanged) + /// Returns a detailed status code and the report ID upon success. + Future> submitTarballSample({ + required Map formData, + required Map imageFiles, + }) async { + // --- Step 1: Submit Text Data Only --- + debugPrint("Step 1: Submitting tarball form data to the server..."); + final dataResult = await _baseService.post('marine/tarball/sample', formData); + + if (dataResult['success'] != true) { + // Data submission failed. This is an L1 failure. + return { + 'status': 'L1', + 'success': false, + 'message': 'Failed to submit data to server: ${dataResult['message']}', + 'reportId': null, + }; + } + debugPrint("Step 1 successful. Tarball data submitted."); + + // --- Step 2: Upload Image Files --- + final recordId = dataResult['data']?['autoid']; + if (recordId == null) { + // Data was saved, but we can't link the images. This is an L2 failure. + return { + 'status': 'L2', + 'success': false, + 'message': 'Data submitted, but failed to get a record ID to link images.', + 'reportId': null, + }; + } + + final filesToUpload = {}; + imageFiles.forEach((key, value) { + if (value != null) filesToUpload[key] = value; + }); + + if (filesToUpload.isEmpty) { + // If there are no images, the process is complete. + return { + 'status': 'L3', + 'success': true, + 'message': 'Data submitted successfully. No images were attached.', + 'reportId': recordId, + }; + } + + debugPrint("Step 2: Uploading ${filesToUpload.length} tarball images for record ID: $recordId"); + final imageResult = await _baseService.postMultipart( + endpoint: 'marine/tarball/images', + fields: {'autoid': recordId.toString()}, + files: filesToUpload, + ); + + if (imageResult['success'] != true) { + // Image upload failed. This is an L2 failure. + return { + 'status': 'L2', + 'success': false, + 'message': 'Data submitted to server, but image upload failed: ${imageResult['message']}', + 'reportId': recordId, + }; + } + + // Both steps were successful. + return { + 'status': 'L3', + 'success': true, + 'message': 'Data and images submitted to server successfully.', + 'reportId': recordId, + }; + } + + /// Orchestrates a two-step submission process for in-situ samples. + Future> submitInSituSample({ + required Map formData, + required Map imageFiles, + }) async { + // --- Step 1: Submit Form Data --- + debugPrint("Step 1: Submitting in-situ form data to the server..."); + final dataResult = await _baseService.post('marine/manual/sample', formData); + + if (dataResult['success'] != true) { + return { + 'status': 'L1', + 'success': false, + 'message': 'Failed to submit in-situ data: ${dataResult['message']}', + 'reportId': null, + }; + } + debugPrint("Step 1 successful. In-situ data submitted."); + + // --- Step 2: Upload Image Files --- + final recordId = dataResult['data']?['man_id']; + if (recordId == null) { + return { + 'status': 'L2', + 'success': false, + 'message': 'In-situ data submitted, but failed to get a record ID for images.', + 'reportId': null, + }; + } + + final filesToUpload = {}; + imageFiles.forEach((key, value) { + if (value != null) filesToUpload[key] = value; + }); + + if (filesToUpload.isEmpty) { + // Handle alert for successful data-only submission. + _handleInSituSuccessAlert(formData, isDataOnly: true); + + return { + 'status': 'L3', + 'success': true, + 'message': 'In-situ data submitted successfully. No images were attached.', + 'reportId': recordId.toString(), + }; + } + + debugPrint("Step 2: Uploading ${filesToUpload.length} in-situ images for record ID: $recordId"); + final imageResult = await _baseService.postMultipart( + endpoint: 'marine/manual/images', + fields: {'man_id': recordId.toString()}, + files: filesToUpload, + ); + + if (imageResult['success'] != true) { + return { + 'status': 'L2', + 'success': false, + 'message': 'In-situ data submitted, but image upload failed: ${imageResult['message']}', + 'reportId': recordId.toString(), + }; + } + + // Handle alert for successful data and image submission. + _handleInSituSuccessAlert(formData, isDataOnly: false); + + return { + 'status': 'L3', + 'success': true, + 'message': 'In-situ data and images submitted successfully.', + 'reportId': recordId.toString(), + }; + } + + /// A private helper method to build and send the detailed in-situ alert. + Future _handleInSituSuccessAlert(Map formData, {required bool isDataOnly}) async { + try { + final groupChatId = await _settingsService.getInSituChatId(); + if (groupChatId.isNotEmpty) { + // Extract data from the formData map with fallbacks + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = formData['man_station_name'] ?? 'N/A'; + final stationCode = formData['man_station_code'] ?? 'N/A'; + final submissionDate = formData['sampling_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); + final submitter = formData['first_sampler_name'] ?? 'N/A'; + final manualsondeID = formData['man_sondeID'] ?? 'N/A'; + //final distanceKm = double.tryParse(formData['distance_difference_km'] ?? '0') ?? 0; + //final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + //final distanceRemarks = formData['distance_difference_remarks']; + + final distanceKm = double.tryParse(formData['man_distance_difference'] ?? '0') ?? 0; + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + final distanceRemarks = formData['man_distance_difference_remarks'] ?? 'N/A'; + + + // Build the message using a StringBuffer for clarity + final buffer = StringBuffer(); + buffer.writeln('✅ *In-Situ Sample ${submissionType} Submitted:*'); + buffer.writeln(); // Blank line + buffer.writeln('*Station Name & Code:* $stationName ($stationCode)'); + buffer.writeln('*Date of Submitted:* $submissionDate'); + buffer.writeln('*Submitted by User:* $submitter'); + buffer.writeln('*Sonde ID:* $manualsondeID'); + buffer.writeln('*Status of Submission:* Successful'); + + // Only include the Alert section if distance or remarks are relevant + if (distanceKm > 0 || (distanceRemarks != null && distanceRemarks.isNotEmpty)) { + buffer.writeln(); // Blank line + buffer.writeln('🔔 *Alert:*'); + buffer.writeln('*Distance from station:* $distanceMeters meters'); + if (distanceRemarks != null && distanceRemarks.isNotEmpty) { + buffer.writeln('*Remarks for distance:* $distanceRemarks'); + } + } + + final String message = buffer.toString(); + + // Try to send immediately, or queue on failure + final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message); + if (!wasSent) { + await _telegramService.queueMessage('marine_in_situ', message); + } + } + } catch (e) { + debugPrint("Failed to handle Telegram alert: $e"); + } + } +} \ No newline at end of file diff --git a/lib/services/river_api_service.dart b/lib/services/river_api_service.dart new file mode 100644 index 0000000..2c87e1e --- /dev/null +++ b/lib/services/river_api_service.dart @@ -0,0 +1,135 @@ +// lib/services/river_api_service.dart + +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; + +import 'package:environment_monitoring_app/services/base_api_service.dart'; +import 'package:environment_monitoring_app/services/telegram_service.dart'; +import 'package:environment_monitoring_app/services/settings_service.dart'; + +class RiverApiService { + final BaseApiService _baseService = BaseApiService(); + final TelegramService _telegramService = TelegramService(); + final SettingsService _settingsService = SettingsService(); + + Future> getManualStations() { + return _baseService.get('river/manual-stations'); + } + + Future> getTriennialStations() { + return _baseService.get('river/triennial-stations'); + } + + Future> submitInSituSample({ + required Map formData, + required Map imageFiles, + }) async { + // --- Step 1: Submit Form Data --- + final dataResult = await _baseService.post('river/manual/sample', formData); + + if (dataResult['success'] != true) { + return { + 'status': 'L1', + 'success': false, + 'message': 'Failed to submit river in-situ data: ${dataResult['message']}', + 'reportId': null + }; + } + + // --- Step 2: Upload Image Files --- + final recordId = dataResult['data']?['r_man_id']; + if (recordId == null) { + return { + 'status': 'L2', + 'success': false, + 'message': 'Data submitted, but failed to get a record ID for images.', + 'reportId': null + }; + } + + final filesToUpload = {}; + imageFiles.forEach((key, value) { + if (value != null) filesToUpload[key] = value; + }); + + if (filesToUpload.isEmpty) { + _handleInSituSuccessAlert(formData, isDataOnly: true); + return { + 'status': 'L3', + 'success': true, + 'message': 'Data submitted successfully. No images were attached.', + 'reportId': recordId.toString() + }; + } + + final imageResult = await _baseService.postMultipart( + endpoint: 'river/manual/images', + fields: {'r_man_id': recordId.toString()}, + files: filesToUpload, + ); + + if (imageResult['success'] != true) { + return { + 'status': 'L2', + 'success': false, + 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', + 'reportId': recordId.toString() + }; + } + + _handleInSituSuccessAlert(formData, isDataOnly: false); + return { + 'status': 'L3', + 'success': true, + 'message': 'Data and images submitted successfully.', + 'reportId': recordId.toString() + }; + } + + Future _handleInSituSuccessAlert(Map formData, {required bool isDataOnly}) async { + try { + final groupChatId = await _settingsService.getInSituChatId(); + if (groupChatId.isNotEmpty) { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = formData['r_man_station_name'] ?? 'N/A'; + final stationCode = formData['r_man_station_code'] ?? 'N/A'; + final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); + final submitter = formData['first_sampler_name'] ?? 'N/A'; + final sondeID = formData['r_man_sondeID'] ?? 'N/A'; + final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0; + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submitted:* $submissionDate') + ..writeln('*Submitted by User:* $submitter') + ..writeln('*Sonde ID:* $sondeID') + ..writeln('*Status of Submission:* Successful'); + + if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { + // CORRECTED: The cascade operator '..' was missing on the next line. + buffer + ..writeln() + ..writeln('🔔 *Alert:*') + ..writeln('*Distance from station:* $distanceMeters meters'); + if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { + buffer.writeln('*Remarks for distance:* $distanceRemarks'); + } + } + + final String message = buffer.toString(); + + final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message); + if (!wasSent) { + await _telegramService.queueMessage('river_in_situ', message); + } + } + } catch (e) { + debugPrint("Failed to handle River Telegram alert: $e"); + } + } +} \ No newline at end of file diff --git a/lib/services/river_in_situ_sampling_service.dart b/lib/services/river_in_situ_sampling_service.dart new file mode 100644 index 0000000..7a91ee8 --- /dev/null +++ b/lib/services/river_in_situ_sampling_service.dart @@ -0,0 +1,160 @@ +// lib/services/river_in_situ_sampling_service.dart + +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:image/image.dart' as img; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; +import 'package:usb_serial/usb_serial.dart'; + +// CHANGED: Import river-specific services and models +import 'location_service.dart'; +import 'river_api_service.dart'; +import '../models/river_in_situ_sampling_data.dart'; +import '../bluetooth/bluetooth_manager.dart'; +import '../serial/serial_manager.dart'; + +/// A dedicated service to handle all business logic for the River In-Situ Sampling feature. +// CHANGED: Renamed class for the River In-Situ Sampling Service +class RiverInSituSamplingService { + final LocationService _locationService = LocationService(); + // CHANGED: Use the river-specific API service + final RiverApiService _riverApiService = RiverApiService(); + final BluetoothManager _bluetoothManager = BluetoothManager(); + final SerialManager _serialManager = SerialManager(); + + // This channel name MUST match the one defined in MainActivity.kt + static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); + + + // --- Location Services --- + Future getCurrentLocation() => _locationService.getCurrentLocation(); + double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2); + + // --- Image Processing --- + Future pickAndProcessImage(ImageSource source, { + // CHANGED: Use the river-specific data model + required RiverInSituSamplingData data, + required String imageInfo, + bool isRequired = false, + }) async { + final picker = ImagePicker(); + final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024); + if (photo == null) return null; + + final bytes = await photo.readAsBytes(); + img.Image? originalImage = img.decodeImage(bytes); + if (originalImage == null) return null; + + if (isRequired && originalImage.height > originalImage.width) { + debugPrint("Image rejected: Must be in landscape orientation."); + return null; + } + + final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}"; + final font = img.arial24; + final textWidth = watermarkTimestamp.length * 12; + img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255)); + img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0)); + + final tempDir = await getTemporaryDirectory(); + // CHANGED: Assumes the station code key for rivers is 'r_man_station_code'. Adjust if necessary. + final stationCode = data.selectedStation?['r_man_station_code'] ?? 'NA'; + final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); + final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; + final filePath = path.join(tempDir.path, newFileName); + + return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); + } + + // --- Device Connection (Delegated to Managers) --- + ValueNotifier get bluetoothConnectionState => _bluetoothManager.connectionState; + ValueNotifier get serialConnectionState => _serialManager.connectionState; + + // This getter now dynamically returns the correct Sonde ID notifier + // based on the active connection, which is essential for the UI. + ValueNotifier get sondeId { + if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) { + return _bluetoothManager.sondeId; + } + return _serialManager.sondeId; + } + + Stream> get bluetoothDataStream => _bluetoothManager.dataStream; + Stream> get serialDataStream => _serialManager.dataStream; + + String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value; + String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value; + + // --- Permissions --- + Future requestDevicePermissions() async { + Map statuses = await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.locationWhenInUse, + ].request(); + + // Return true only if the essential permissions are granted. + if (statuses[Permission.bluetoothScan] == PermissionStatus.granted && + statuses[Permission.bluetoothConnect] == PermissionStatus.granted) { + return true; + } else { + return false; + } + } + + // --- Bluetooth Methods --- + Future> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices(); + Future connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device); + void disconnectFromBluetooth() => _bluetoothManager.disconnect(); + void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); + void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading(); + + + // --- USB Serial Methods --- + Future> getAvailableSerialDevices() => _serialManager.getAvailableDevices(); + + Future requestUsbPermission(UsbDevice device) async { + try { + return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false; + } on PlatformException catch (e) { + debugPrint("Failed to request USB permission: '${e.message}'."); + return false; + } + } + + Future connectToSerialDevice(UsbDevice device) async { + final bool permissionGranted = await requestUsbPermission(device); + if (permissionGranted) { + await _serialManager.connect(device); + } else { + throw Exception("USB permission was not granted."); + } + } + + void disconnectFromSerial() => _serialManager.disconnect(); + void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); + void stopSerialAutoReading() => _serialManager.stopAutoReading(); + + + void dispose() { + _bluetoothManager.dispose(); + _serialManager.dispose(); + } + + // --- Data Submission --- + // CHANGED: Use the river-specific data model + Future> submitData(RiverInSituSamplingData data) { + // CHANGED: Call the river-specific API service method + return _riverApiService.submitInSituSample( + formData: data.toApiFormData(), + imageFiles: data.toApiImageFiles(), + ); + } +} \ No newline at end of file diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart new file mode 100644 index 0000000..2a0c3b3 --- /dev/null +++ b/lib/services/settings_service.dart @@ -0,0 +1,42 @@ +// lib/services/settings_service.dart + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:environment_monitoring_app/services/base_api_service.dart'; + +class SettingsService { + final BaseApiService _baseService = BaseApiService(); + static const _inSituChatIdKey = 'telegram_in_situ_chat_id'; + static const _tarballChatIdKey = 'telegram_tarball_chat_id'; + + /// Fetches settings from the server and saves them to local storage. + Future syncFromServer() async { + final result = await _baseService.get('settings'); + + if (result['success'] == true && result['data'] is Map) { + final settings = result['data'] as Map; + final prefs = await SharedPreferences.getInstance(); + + // Save the chat IDs from the nested map + final inSituSettings = settings['marine_in_situ'] as Map?; + await prefs.setString(_inSituChatIdKey, inSituSettings?['telegram_chat_id'] ?? ''); + + final tarballSettings = settings['marine_tarball'] as Map?; + await prefs.setString(_tarballChatIdKey, tarballSettings?['telegram_chat_id'] ?? ''); + + return true; + } + return false; + } + + /// Gets the locally stored Chat ID for the In-Situ module. + Future getInSituChatId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_inSituChatIdKey) ?? ''; + } + + /// Gets the locally stored Chat ID for the Tarball module. + Future getTarballChatId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_tarballChatIdKey) ?? ''; + } +} \ No newline at end of file diff --git a/lib/services/telegram_service.dart b/lib/services/telegram_service.dart new file mode 100644 index 0000000..6bef8ee --- /dev/null +++ b/lib/services/telegram_service.dart @@ -0,0 +1,113 @@ +import 'package:flutter/foundation.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/settings_service.dart'; + +class TelegramService { + final ApiService _apiService = ApiService(); + final DatabaseHelper _dbHelper = DatabaseHelper(); + final SettingsService _settingsService = SettingsService(); + + bool _isProcessing = false; + + // --- ADDED: New method to attempt immediate sending --- + /// Tries to send an alert immediately over the network. + /// Returns `true` on success, `false` on failure. + Future sendAlertImmediately(String module, String message) async { + debugPrint("[TelegramService] Attempting to send alert immediately..."); + String chatId = ''; + if (module == 'marine_in_situ') { + chatId = await _settingsService.getInSituChatId(); + } else if (module == 'marine_tarball') { + chatId = await _settingsService.getTarballChatId(); + } + + if (chatId.isEmpty) { + debugPrint("[TelegramService] ❌ Cannot send immediately. Chat ID for module '$module' is not configured."); + return false; // Cannot succeed if no chat ID is set. + } + + final result = await _apiService.sendTelegramAlert( + chatId: chatId, + message: message, + ); + + if (result['success'] == true) { + debugPrint("[TelegramService] ✅ Alert sent immediately."); + return true; + } else { + debugPrint("[TelegramService] ❌ Immediate send failed. Reason: ${result['message']}"); + return false; + } + } + + /// Saves an alert to the local database queue. (This is now the fallback) + Future queueMessage(String module, String message) async { + String chatId = ''; + if (module == 'marine_in_situ') { + chatId = await _settingsService.getInSituChatId(); + } else if (module == 'marine_tarball') { + chatId = await _settingsService.getTarballChatId(); + } + + if (chatId.isEmpty) { + debugPrint("[TelegramService] ❌ ERROR: Cannot queue alert. Chat ID for module '$module' is not configured."); + return; + } + + debugPrint("[TelegramService] ⬇️ Immediate send failed. Saving alert to local queue."); + final db = await _dbHelper.database; + await db.insert( + 'alert_queue', + { + 'chat_id': chatId, + 'message': message, + 'created_at': DateTime.now().toIso8601String(), + }, + ); + debugPrint("[TelegramService] ✅ Alert queued for module: $module"); + } + + /// Processes all pending alerts in the queue. (Unchanged) + Future processAlertQueue() async { + if (_isProcessing) { + debugPrint("[TelegramService] ⏳ Queue is already being processed. Skipping."); + return; + } + + _isProcessing = true; + debugPrint("[TelegramService] ▶️ Starting to process alert queue..."); + + final db = await _dbHelper.database; + final List> pendingAlerts = await db.query('alert_queue', orderBy: 'created_at'); + + if (pendingAlerts.isEmpty) { + debugPrint("[TelegramService] ⏹️ Queue is empty. Nothing to process."); + _isProcessing = false; + return; + } + + debugPrint("[TelegramService] 🔎 Found ${pendingAlerts.length} pending alerts."); + + for (var alert in pendingAlerts) { + final alertId = alert['id']; + final chatId = alert['chat_id']; + debugPrint("[TelegramService] - Processing alert ID: $alertId for Chat ID: $chatId"); + + final result = await _apiService.sendTelegramAlert( + chatId: chatId, + message: alert['message'], + ); + + if (result['success'] == true) { + await db.delete('alert_queue', where: 'id = ?', whereArgs: [alertId]); + debugPrint("[TelegramService] ✅ SUCCESS: Alert ID $alertId sent and removed from queue."); + } else { + debugPrint("[TelegramService] ❌ FAILED: Alert ID $alertId could not be sent. Reason: ${result['message']}"); + } + } + + debugPrint("[TelegramService] ⏹️ Finished processing alert queue."); + _isProcessing = false; + } +} \ No newline at end of file diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..2813460 --- /dev/null +++ b/lib/theme.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class AppTheme { + static ThemeData get darkBlueTheme { + return ThemeData( + brightness: Brightness.dark, + primaryColor: Colors.blue[900], + scaffoldBackgroundColor: Colors.grey[900], + appBarTheme: AppBarTheme( + backgroundColor: Colors.blue[900], + foregroundColor: Colors.white, + elevation: 0, + titleTextStyle: GoogleFonts.roboto( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + textTheme: GoogleFonts.robotoTextTheme().apply( + bodyColor: Colors.white, + displayColor: Colors.white, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + textStyle: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey[850], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + hintStyle: TextStyle(color: Colors.white70), + labelStyle: TextStyle(color: Colors.white), + ), + cardColor: Colors.grey[850], + iconTheme: IconThemeData(color: Colors.white), + dividerColor: Colors.white24, + ); + } +} \ No newline at end of file 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..0e385cb --- /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 "environment_monitoring_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.environment_monitoring_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..64a0ece --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#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); +} 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..2db3c22 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..17ec4f2 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "environment_monitoring_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "environment_monitoring_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..f92cb6d --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,24 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import connectivity_plus +import file_picker +import file_selector_macos +import geolocator_apple +import path_provider_foundation +import shared_preferences_foundation +import sqflite_darwin + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f952639 --- /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 /* environment_monitoring_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "environment_monitoring_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* environment_monitoring_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* environment_monitoring_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.environmentMonitoringApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/environment_monitoring_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/environment_monitoring_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.environmentMonitoringApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/environment_monitoring_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/environment_monitoring_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.environmentMonitoringApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/environment_monitoring_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/environment_monitoring_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ec54886 --- /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..b3a271f --- /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 = environment_monitoring_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.environmentMonitoringApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..e4fc48a --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,939 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + 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: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + 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: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + 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" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "051849e2bd7c7b3bc5844ea0d096609ddc3a859890ec3a9ac4a65a2620cc1f99" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + dropdown_search: + dependency: "direct main" + description: + name: dropdown_search + sha256: "55106e8290acaa97ed15bea1fdad82c3cf0c248dd410e651f5a8ac6870f783ab" + url: "https://pub.dev" + source: hosted + version: "5.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: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" + url: "https://pub.dev" + source: hosted + version: "0.9.4+3" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bluetooth_serial: + dependency: "direct main" + description: + path: "." + ref: my-edits + resolved-ref: "8b87c191ae658e1b59fe2e73e19d55c76dcb5fe5" + url: "https://github.com/PSTPSYCO/flutter_bluetooth_serial.git" + source: git + version: "0.4.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + 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" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27" + url: "https://pub.dev" + source: hosted + version: "11.1.0" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + http: + dependency: "direct main" + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6fae381e6af2bbe0365a5e4ce1db3959462fa0c4d234facf070746024bb80c8d" + url: "https://pub.dev" + source: hosted + version: "0.8.12+24" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" + url: "https://pub.dev" + source: hosted + version: "0.8.12+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + path: + dependency: "direct main" + 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: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_barcode_scanner: + dependency: "direct main" + description: + name: simple_barcode_scanner + sha256: "2b6ec05e10fbf4f07687f3687c5cf46d3dcf873492e0a5758211bd957c854113" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" + url: "https://pub.dev" + source: hosted + version: "2.5.5" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + 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" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.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: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + usb_serial: + dependency: "direct main" + description: + name: usb_serial + sha256: a605a600e34e7f28d4e80851ca3999ef747e42e406138887b8a88b8c382a8b07 + url: "https://pub.dev" + source: hosted + version: "0.5.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" + url: "https://pub.dev" + source: hosted + version: "1.1.17" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webview_windows: + dependency: transitive + description: + name: webview_windows + sha256: "47fcad5875a45db29dbb5c9e6709bf5c88dcc429049872701343f91ed7255730" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.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: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..0660998 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,67 @@ +name: environment_monitoring_app +description: "A new Flutter project." + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.1.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + # --- Core Packages --- + provider: ^6.1.1 + http: ^1.2.1 + intl: ^0.18.1 + + # --- Local Storage & Offline Capabilities --- + shared_preferences: ^2.2.3 + sqflite: ^2.3.3 + path_provider: ^2.1.3 + path: ^1.8.3 # Explicitly added for path manipulation + connectivity_plus: ^6.0.1 + + # --- UI Components & Utilities --- + cupertino_icons: ^1.0.8 + flutter_svg: ^2.0.9 + google_fonts: ^6.1.0 + dropdown_search: ^5.0.6 # For searchable dropdowns in forms + + # --- Device & Hardware Access --- + image_picker: ^1.0.7 + file_picker: ^8.0.0+1 + geolocator: ^11.0.0 # For GPS functionality + image: ^4.1.3 # For image processing (watermarks) + permission_handler: ^11.3.1 + # --- Added for In-Situ Sampling Module --- + simple_barcode_scanner: ^0.3.0 # For scanning sample IDs + #flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection + + flutter_bluetooth_serial: + git: + url: https://github.com/PSTPSYCO/flutter_bluetooth_serial.git + ref: my-edits + + usb_serial: ^0.5.2 # For USB Serial sonde connection + + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^3.0.0 + flutter_launcher_icons: ^0.13.1 + +flutter: + uses-material-design: true + + assets: + - assets/ + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icon_2_512x512.png" \ No newline at end of file diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..270bf6a --- /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:environment_monitoring_app/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8e5680d --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + environment_monitoring_app + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..6eb3e8e --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "environment_monitoring_app", + "short_name": "environment_monitoring_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..dbe5d7a --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(environment_monitoring_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "environment_monitoring_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..3bf1db5 --- /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) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + WebviewWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebviewWindowsPlugin")); +} 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..d4c7652 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,28 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus + file_selector_windows + geolocator_windows + permission_handler_windows + webview_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..04e1d82 --- /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", "environment_monitoring_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "environment_monitoring_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "environment_monitoring_app.exe" "\0" + VALUE "ProductName", "environment_monitoring_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..0faa875 --- /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"environment_monitoring_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_