diff --git a/creche_app/.gitignore b/creche_app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/creche_app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/creche_app/.metadata b/creche_app/.metadata new file mode 100644 index 0000000..26d3e69 --- /dev/null +++ b/creche_app/.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: "67323de285b00232883f53b84095eb72be97d35c" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 67323de285b00232883f53b84095eb72be97d35c + base_revision: 67323de285b00232883f53b84095eb72be97d35c + - platform: android + create_revision: 67323de285b00232883f53b84095eb72be97d35c + base_revision: 67323de285b00232883f53b84095eb72be97d35c + - platform: ios + create_revision: 67323de285b00232883f53b84095eb72be97d35c + base_revision: 67323de285b00232883f53b84095eb72be97d35c + - platform: linux + create_revision: 67323de285b00232883f53b84095eb72be97d35c + base_revision: 67323de285b00232883f53b84095eb72be97d35c + - platform: macos + create_revision: 67323de285b00232883f53b84095eb72be97d35c + base_revision: 67323de285b00232883f53b84095eb72be97d35c + - platform: web + create_revision: 67323de285b00232883f53b84095eb72be97d35c + base_revision: 67323de285b00232883f53b84095eb72be97d35c + - platform: windows + create_revision: 67323de285b00232883f53b84095eb72be97d35c + base_revision: 67323de285b00232883f53b84095eb72be97d35c + + # 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/creche_app/.vscode/settings.json b/creche_app/.vscode/settings.json new file mode 100644 index 0000000..8932098 --- /dev/null +++ b/creche_app/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "flutterTools.displayGetxContextMenu": false, + "flutterTools.displayMobxContextMenu": false, + "flutterTools.displayRiverpodContextMenu": true, + "flutterTools.displayModularContextMenu": false +} \ No newline at end of file diff --git a/creche_app/README.md b/creche_app/README.md new file mode 100644 index 0000000..d124c1f --- /dev/null +++ b/creche_app/README.md @@ -0,0 +1,16 @@ +# creche_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/creche_app/analysis_options.yaml b/creche_app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/creche_app/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/creche_app/android/.gitignore b/creche_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/creche_app/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/creche_app/android/app/build.gradle.kts b/creche_app/android/app/build.gradle.kts new file mode 100644 index 0000000..37ab22d --- /dev/null +++ b/creche_app/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "Hilaritech.com.creche_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "Hilaritech.com.creche_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/creche_app/android/app/src/debug/AndroidManifest.xml b/creche_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/creche_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/creche_app/android/app/src/main/AndroidManifest.xml b/creche_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c795fec --- /dev/null +++ b/creche_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/creche_app/android/app/src/main/kotlin/Hilaritech/com/creche_app/MainActivity.kt b/creche_app/android/app/src/main/kotlin/Hilaritech/com/creche_app/MainActivity.kt new file mode 100644 index 0000000..d572f9b --- /dev/null +++ b/creche_app/android/app/src/main/kotlin/Hilaritech/com/creche_app/MainActivity.kt @@ -0,0 +1,5 @@ +package Hilaritech.com.creche_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/creche_app/android/app/src/main/res/drawable-v21/launch_background.xml b/creche_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/creche_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/creche_app/android/app/src/main/res/drawable/launch_background.xml b/creche_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/creche_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/creche_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/creche_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/creche_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/creche_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/creche_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/creche_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/creche_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/creche_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/creche_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/creche_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/creche_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/creche_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/creche_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/creche_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/creche_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/creche_app/android/app/src/main/res/values-night/styles.xml b/creche_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/creche_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/creche_app/android/app/src/main/res/values/styles.xml b/creche_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/creche_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/creche_app/android/app/src/profile/AndroidManifest.xml b/creche_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/creche_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/creche_app/android/build.gradle.kts b/creche_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/creche_app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/creche_app/android/gradle.properties b/creche_app/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/creche_app/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/creche_app/android/gradle/wrapper/gradle-wrapper.properties b/creche_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/creche_app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/creche_app/android/settings.gradle.kts b/creche_app/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/creche_app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/creche_app/assets/logo.png b/creche_app/assets/logo.png new file mode 100644 index 0000000..fcc1947 Binary files /dev/null and b/creche_app/assets/logo.png differ diff --git a/creche_app/assets/splash_animation.json b/creche_app/assets/splash_animation.json new file mode 100644 index 0000000..73016ac --- /dev/null +++ b/creche_app/assets/splash_animation.json @@ -0,0 +1 @@ +{"v":"5.5.7","fr":30,"ip":0,"op":60,"w":200,"h":200,"layers":[]} diff --git a/creche_app/assets/waiting.json b/creche_app/assets/waiting.json new file mode 100644 index 0000000..73016ac --- /dev/null +++ b/creche_app/assets/waiting.json @@ -0,0 +1 @@ +{"v":"5.5.7","fr":30,"ip":0,"op":60,"w":200,"h":200,"layers":[]} diff --git a/creche_app/ios/.gitignore b/creche_app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/creche_app/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/creche_app/ios/Flutter/AppFrameworkInfo.plist b/creche_app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/creche_app/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 + 13.0 + + diff --git a/creche_app/ios/Flutter/Debug.xcconfig b/creche_app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/creche_app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/creche_app/ios/Flutter/Release.xcconfig b/creche_app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/creche_app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/creche_app/ios/Runner.xcodeproj/project.pbxproj b/creche_app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..14946b3 --- /dev/null +++ b/creche_app/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 = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = Hilari-tech.com.crecheApp; + 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 = Hilari-tech.com.crecheApp.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 = Hilari-tech.com.crecheApp.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 = Hilari-tech.com.crecheApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = Hilari-tech.com.crecheApp; + 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 = Hilari-tech.com.crecheApp; + 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/creche_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/creche_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/creche_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/creche_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/creche_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/creche_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/creche_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/creche_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/creche_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/creche_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/creche_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/creche_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/creche_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/creche_app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/creche_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/creche_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/creche_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/creche_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/creche_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/creche_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/creche_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/creche_app/ios/Runner/AppDelegate.swift b/creche_app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/creche_app/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/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/creche_app/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/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/creche_app/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/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/creche_app/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/creche_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/creche_app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/creche_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/creche_app/ios/Runner/Base.lproj/Main.storyboard b/creche_app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/creche_app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/creche_app/ios/Runner/Info.plist b/creche_app/ios/Runner/Info.plist new file mode 100644 index 0000000..036276a --- /dev/null +++ b/creche_app/ios/Runner/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + io.sementesfuturo.creche + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Creche App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + creche_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/creche_app/ios/Runner/Runner-Bridging-Header.h b/creche_app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/creche_app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/creche_app/ios/RunnerTests/RunnerTests.swift b/creche_app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/creche_app/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/creche_app/lib/core/auth_provider.dart b/creche_app/lib/core/auth_provider.dart new file mode 100644 index 0000000..c90284d --- /dev/null +++ b/creche_app/lib/core/auth_provider.dart @@ -0,0 +1,162 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:http/http.dart' as http; +import '/models/profile.dart'; +import '/models/daily_access_approval.dart'; +import '/models/creche_settings.dart'; + +part 'auth_provider.g.dart'; + +// Provider para a sessão atual +@riverpod +Future currentSession(CurrentSessionRef ref) { + return Future.value(Supabase.instance.client.auth.currentSession); +} + +// Provider para o perfil do utilizador logado +@riverpod +Future currentProfile(CurrentProfileRef ref) async { + final session = await ref.watch(currentSessionProvider.future); + if (session == null) return null; + final data = await Supabase.instance.client + .from('profiles') + .select() + .eq('user_id', session.user.id) + .maybeSingle(); + if (data == null) return null; + return Profile.fromMap(data); +} + +@riverpod +class AuthNotifier extends _$AuthNotifier { + final _storage = const FlutterSecureStorage(); + final _localAuth = LocalAuthentication(); + + @override + Future build() async { + final token = await _storage.read(key: 'access_token'); + if (token != null) { + try { + await Supabase.instance.client.auth.setSession(token); + ref.invalidate(currentSessionProvider); + ref.invalidate(currentProfileProvider); + } catch (_) { + await _storage.delete(key: 'access_token'); + } + } + } + + Future signIn(String email, String password) async { + final supabase = Supabase.instance.client; + final response = await supabase.auth.signInWithPassword( + email: email, + password: password, + ); + await _storage.write( + key: 'access_token', value: response.session!.accessToken); + await _performSecurityChecks(); + ref.invalidate(currentSessionProvider); + ref.invalidate(currentProfileProvider); + } + + Future biometricSignIn() async { + final canAuthenticate = await _localAuth.canCheckBiometrics; + if (!canAuthenticate) throw Exception('Biometria não disponível'); + + final didAuthenticate = await _localAuth.authenticate( + localizedReason: 'Autentique para entrar no Diário do Candengue', + ); + if (!didAuthenticate) throw Exception('Falha na autenticação biométrica'); + + final token = await _storage.read(key: 'access_token'); + if (token == null) throw Exception('Sem sessão guardada. Faça login primeiro.'); + + await Supabase.instance.client.auth.setSession(token); + await _performSecurityChecks(); + ref.invalidate(currentSessionProvider); + ref.invalidate(currentProfileProvider); + } + + Future _performSecurityChecks() async { + final supabase = Supabase.instance.client; + final user = supabase.auth.currentUser; + if (user == null) throw Exception('Utilizador não autenticado'); + + // Verificar IP + final ipRes = await http.get(Uri.parse('https://api.ipify.org?format=text')); + final ip = ipRes.body.trim(); + + // Verificar localização + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + // Carregar configurações da creche + final settingsData = + await supabase.from('creche_settings').select().single(); + final settings = CrecheSettings.fromMap(settingsData); + + // Verificar IP (se lista não vazia) + if (settings.allowedIps.isNotEmpty && !settings.allowedIps.contains(ip)) { + throw Exception('Endereço IP não autorizado ($ip)'); + } + + // Verificar geofence (se coordenadas configuradas) + if (settings.geofenceLat != null && settings.geofenceLng != null) { + final distance = Geolocator.distanceBetween( + position.latitude, + position.longitude, + settings.geofenceLat!, + settings.geofenceLng!, + ); + if (distance > settings.geofenceRadiusMeters) { + throw Exception( + 'Fora da área permitida da creche (${distance.toInt()}m)'); + } + } + + // Verificar aprovação diária para teacher/staff + final profileData = await supabase + .from('profiles') + .select() + .eq('user_id', user.id) + .single(); + final profile = Profile.fromMap(profileData); + + if (profile.role == 'teacher' || profile.role == 'staff') { + final today = DateTime.now().toIso8601String().split('T')[0]; + final approvalData = await supabase + .from('daily_access_approvals') + .select() + .eq('user_id', profile.id) + .eq('approval_date', today) + .maybeSingle(); + + if (approvalData == null || + DailyAccessApproval.fromMap(approvalData).status != 'approved') { + // Lança exceção especial para redirecionar para sala de espera + throw WaitingApprovalException(); + } + } + } + + Future signOut() async { + await Supabase.instance.client.auth.signOut(); + await _storage.delete(key: 'access_token'); + ref.invalidate(currentSessionProvider); + ref.invalidate(currentProfileProvider); + } +} + +// Exceção especial para sala de espera +class WaitingApprovalException implements Exception { + @override + String toString() => 'Acesso pendente de aprovação da Diretora'; +} diff --git a/creche_app/lib/core/auth_provider.g.dart b/creche_app/lib/core/auth_provider.g.dart new file mode 100644 index 0000000..48eb06b --- /dev/null +++ b/creche_app/lib/core/auth_provider.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$currentSessionHash() => r'currentSessionHash'; + +@ProviderFor(currentSession) +final currentSessionProvider = AutoDisposeFutureProvider.internal( + currentSession, + name: r'currentSessionProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$currentSessionHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +typedef CurrentSessionRef = AutoDisposeFutureProviderRef; + +String _$currentProfileHash() => r'currentProfileHash'; + +@ProviderFor(currentProfile) +final currentProfileProvider = AutoDisposeFutureProvider.internal( + currentProfile, + name: r'currentProfileProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$currentProfileHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +typedef CurrentProfileRef = AutoDisposeFutureProviderRef; + +String _$authNotifierHash() => r'authNotifierHash'; + +@ProviderFor(AuthNotifier) +final authNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + AuthNotifier.new, + name: r'authNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$authNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AuthNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/creche_app/lib/core/routes.dart b/creche_app/lib/core/routes.dart new file mode 100644 index 0000000..a7407c9 --- /dev/null +++ b/creche_app/lib/core/routes.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'auth_provider.dart'; +import '../features/auth/login_screen.dart'; +import '../features/auth/waiting_approval_screen.dart'; +import '../features/splash/splash_screen.dart'; +import '../features/home/home_dashboard.dart'; +import '../features/children/children_list_screen.dart'; +import '../features/children/child_detail_screen.dart'; +import '../features/diary/new_diary_screen.dart'; +import '../features/diary/diary_history_screen.dart'; +import '../features/attendance/attendance_screen.dart'; +import '../features/payments/payments_screen.dart'; +import '../features/announcements/announcements_screen.dart'; +import '../features/chat/chat_list_screen.dart'; +import '../features/chat/chat_screen.dart'; +import '../features/profile/profile_screen.dart'; +import '../features/users/users_management_screen.dart'; +import '../features/settings/settings_screen.dart'; +import '../features/medication/medication_screen.dart'; +import '../features/menu/menu_screen.dart'; + +const _adminRoles = ['principal', 'admin']; +const _staffRoles = ['principal', 'admin', 'teacher', 'staff']; +const _allRoles = ['principal', 'admin', 'teacher', 'staff', 'parent']; + +final goRouterProvider = Provider((ref) { + return GoRouter( + initialLocation: '/splash', + redirect: (context, state) { + final session = Supabase.instance.client.auth.currentSession; + final publicPaths = ['/login', '/splash', '/waiting']; + final isPublic = publicPaths.any((p) => state.fullPath?.startsWith(p) ?? false); + if (session == null && !isPublic) return '/login'; + return null; + }, + routes: [ + GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), + GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + GoRoute(path: '/waiting', builder: (_, __) => const WaitingApprovalScreen()), + GoRoute(path: '/home', builder: (_, __) => const HomeDashboard()), + GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()), + + // ── Todos (incluindo encarregados) ───────────────────── + GoRoute(path: '/chat', builder: (_, __) => const _Guard(roles: _allRoles, child: ChatListScreen())), + GoRoute(path: '/chat/:userId', builder: (_, state) => _Guard( + roles: _allRoles, child: ChatScreen(toUserId: state.pathParameters['userId']!))), + GoRoute(path: '/menu', builder: (_, __) => const _Guard(roles: _allRoles, child: MenuScreen())), + GoRoute(path: '/medication', builder: (_, __) => const _Guard(roles: _allRoles, child: MedicationScreen())), + GoRoute(path: '/announcements', builder: (_, __) => const _Guard(roles: _allRoles, child: AnnouncementsScreen())), + + // ── Staff (educadoras, auxiliares, admins) ───────────── + GoRoute(path: '/children', builder: (_, __) => const _Guard(roles: _staffRoles, child: ChildrenListScreen())), + GoRoute(path: '/child/:id', builder: (_, state) => _Guard( + roles: _staffRoles, child: ChildDetailScreen(id: state.pathParameters['id']!))), + GoRoute(path: '/new-diary', builder: (_, state) => _Guard( + roles: _staffRoles, child: NewDiaryScreen(childId: state.uri.queryParameters['childId']))), + GoRoute(path: '/diary-history/:childId', builder: (_, state) => _Guard( + roles: _staffRoles, child: DiaryHistoryScreen(childId: state.pathParameters['childId']!))), + GoRoute(path: '/attendance', builder: (_, __) => const _Guard(roles: _staffRoles, child: AttendanceScreen())), + + // ── Admin / Principal apenas ─────────────────────────── + GoRoute(path: '/payments', builder: (_, __) => const _Guard(roles: _adminRoles, child: PaymentsScreen())), + GoRoute(path: '/users', builder: (_, __) => const _Guard(roles: _adminRoles, child: UsersManagementScreen())), + GoRoute(path: '/settings', builder: (_, __) => const _Guard(roles: _adminRoles, child: SettingsScreen())), + ], + ); +}); + +class _Guard extends ConsumerWidget { + final List roles; + final Widget child; + const _Guard({required this.roles, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(currentProfileProvider); + return async.when( + data: (profile) { + if (profile != null && roles.contains(profile.role)) return child; + return Scaffold( + backgroundColor: const Color(0xFF0D1117), + body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Icon(Icons.lock_outline, color: Color(0xFFE74C3C), size: 64), + const SizedBox(height: 16), + const Text('Acesso Não Autorizado', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('A tua função não tem permissão para esta área.', + style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 13)), + const SizedBox(height: 24), + TextButton(onPressed: () => context.go('/home'), + child: const Text('← Voltar ao início', style: TextStyle(color: Color(0xFF4FC3F7)))), + ])), + ); + }, + loading: () => const Scaffold(backgroundColor: Color(0xFF0D1117), + body: Center(child: CircularProgressIndicator(color: Color(0xFF4FC3F7)))), + error: (_, __) => const Scaffold(body: Center(child: Text('Erro'))), + ); + } +} diff --git a/creche_app/lib/core/supabase_client.dart b/creche_app/lib/core/supabase_client.dart new file mode 100644 index 0000000..eaf164f --- /dev/null +++ b/creche_app/lib/core/supabase_client.dart @@ -0,0 +1,4 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final supabaseProvider = Provider((ref) => Supabase.instance.client); diff --git a/creche_app/lib/features/announcements/announcements_screen.dart b/creche_app/lib/features/announcements/announcements_screen.dart new file mode 100644 index 0000000..96ab246 --- /dev/null +++ b/creche_app/lib/features/announcements/announcements_screen.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '/models/announcement.dart'; + +class AnnouncementsScreen extends ConsumerStatefulWidget { + const AnnouncementsScreen({super.key}); + + @override + ConsumerState createState() => + _AnnouncementsScreenState(); +} + +class _AnnouncementsScreenState extends ConsumerState { + String? _filterRole; + final _titleController = TextEditingController(); + final _contentController = TextEditingController(); + String? _targetRole; + + @override + void dispose() { + _titleController.dispose(); + _contentController.dispose(); + super.dispose(); + } + + void _showNewAnnouncementDialog() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: const Color(0xFF16213E), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + left: 20, + right: 20, + top: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Novo Aviso', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + TextField( + controller: _titleController, + style: const TextStyle(color: Colors.white), + decoration: _inputDec('Título', Icons.title), + ), + const SizedBox(height: 12), + TextField( + controller: _contentController, + maxLines: 4, + style: const TextStyle(color: Colors.white), + decoration: _inputDec('Conteúdo do aviso...', Icons.message), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: _targetRole, + dropdownColor: const Color(0xFF16213E), + style: const TextStyle(color: Colors.white), + decoration: _inputDec('Público-alvo', Icons.group), + items: const [ + DropdownMenuItem(value: null, child: Text('Todos')), + DropdownMenuItem(value: 'parent', child: Text('Encarregados')), + DropdownMenuItem( + value: 'teacher', child: Text('Educadoras')), + DropdownMenuItem(value: 'staff', child: Text('Funcionários')), + ], + onChanged: (v) => setState(() => _targetRole = v), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _saveAnnouncement, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF4FC3F7), + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Publicar Aviso', + style: TextStyle(color: Colors.white)), + ), + const SizedBox(height: 20), + ], + ), + ), + ); + } + + Future _saveAnnouncement() async { + if (_titleController.text.trim().isEmpty) return; + final supabase = Supabase.instance.client; + await supabase.from('announcements').insert({ + 'title': _titleController.text.trim(), + 'content': _contentController.text.trim(), + 'target_role': _targetRole, + }); + _titleController.clear(); + _contentController.clear(); + if (mounted) Navigator.pop(context); + } + + InputDecoration _inputDec(String hint, IconData icon) { + return InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Color(0xFF888888)), + prefixIcon: Icon(icon, color: const Color(0xFF4FC3F7)), + filled: true, + fillColor: const Color(0xFF1A1A2E), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFF333366)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFF4FC3F7), width: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final supabase = Supabase.instance.client; + + return Scaffold( + backgroundColor: const Color(0xFF1A1A2E), + appBar: AppBar( + backgroundColor: const Color(0xFF16213E), + title: + const Text('Avisos', style: TextStyle(color: Color(0xFF4FC3F7))), + ), + body: Column( + children: [ + // Filtro tabs + Container( + color: const Color(0xFF16213E), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'Todos', + selected: _filterRole == null, + onTap: () => setState(() => _filterRole = null)), + const SizedBox(width: 8), + _FilterChip( + label: 'Encarregados', + selected: _filterRole == 'parent', + onTap: () => + setState(() => _filterRole = 'parent')), + const SizedBox(width: 8), + _FilterChip( + label: 'Funcionários', + selected: _filterRole == 'teacher', + onTap: () => + setState(() => _filterRole = 'teacher')), + ], + ), + ), + ), + + // Lista + Expanded( + child: StreamBuilder>>( + stream: + supabase.from('announcements').stream(primaryKey: ['id']), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator( + color: Color(0xFF4FC3F7))); + } + + var list = snapshot.data! + .map(Announcement.fromMap) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + if (_filterRole != null) { + list = list + .where((a) => + a.targetRole == null || + a.targetRole == _filterRole) + .toList(); + } + + if (list.isEmpty) { + return const Center( + child: Text('Sem avisos', + style: TextStyle(color: Color(0xFF888888)))); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: list.length, + itemBuilder: (context, i) { + final ann = list[i]; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF16213E), + borderRadius: BorderRadius.circular(14), + border: + Border.all(color: const Color(0xFF333366)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(ann.title, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15)), + ), + if (ann.targetRole != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF4FC3F7) + .withOpacity(0.15), + borderRadius: + BorderRadius.circular(20), + ), + child: Text(ann.targetRole!, + style: const TextStyle( + color: Color(0xFF4FC3F7), + fontSize: 11)), + ), + ], + ), + const SizedBox(height: 8), + Text(ann.content, + style: const TextStyle( + color: Color(0xFF888888), + fontSize: 13)), + const SizedBox(height: 8), + Text( + DateFormat('d MMM yyyy, HH:mm', 'pt_PT') + .format(ann.createdAt), + style: const TextStyle( + color: Color(0xFF555577), fontSize: 11), + ), + ], + ), + ); + }, + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: const Color(0xFF4FC3F7), + icon: const Icon(Icons.add, color: Colors.white), + label: const Text('Novo Aviso', + style: TextStyle(color: Colors.white)), + onPressed: _showNewAnnouncementDialog, + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + const _FilterChip( + {required this.label, + required this.selected, + required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: selected + ? const Color(0xFF4FC3F7) + : const Color(0xFF333366), + borderRadius: BorderRadius.circular(20), + ), + child: Text(label, + style: TextStyle( + color: selected ? Colors.white : const Color(0xFF888888), + fontSize: 13, + fontWeight: selected ? FontWeight.bold : FontWeight.normal)), + ), + ); + } +} diff --git a/creche_app/lib/features/attendance/attendance_screen.dart b/creche_app/lib/features/attendance/attendance_screen.dart new file mode 100644 index 0000000..c77a0a8 --- /dev/null +++ b/creche_app/lib/features/attendance/attendance_screen.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '/core/supabase_client.dart'; +import '/models/child.dart'; + +class AttendanceScreen extends ConsumerStatefulWidget { + const AttendanceScreen({super.key}); + + @override + ConsumerState createState() => _AttendanceScreenState(); +} + +class _AttendanceScreenState extends ConsumerState { + DateTime _selectedDate = DateTime.now(); + final Map _presence = {}; + final Map _timeIn = {}; + bool _isSaving = false; + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2024), + lastDate: DateTime.now(), + builder: (c, child) => Theme( + data: ThemeData.dark() + .copyWith(colorScheme: const ColorScheme.dark(primary: Color(0xFF4FC3F7))), + child: child!, + ), + ); + if (picked != null) setState(() => _selectedDate = picked); + } + + Future _markAllPresent(List children) async { + setState(() { + for (final c in children) { + _presence[c.id] = true; + } + }); + } + + Future _saveAttendance(List children) async { + setState(() => _isSaving = true); + final supabase = ref.read(supabaseProvider); + final today = _selectedDate.toIso8601String().split('T')[0]; + try { + for (final child in children) { + final isPresent = _presence[child.id] ?? false; + await supabase.from('attendance').upsert({ + 'child_id': child.id, + 'date': today, + 'status': isPresent ? 'present' : 'absent', + 'time_in': _timeIn[child.id], + }); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Presença guardada com sucesso!'), + backgroundColor: Color(0xFFA5D6A7), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erro: $e'), backgroundColor: Colors.red)); + } + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final supabase = ref.read(supabaseProvider); + + return Scaffold( + backgroundColor: const Color(0xFF1A1A2E), + appBar: AppBar( + backgroundColor: const Color(0xFF16213E), + title: const Text('Presença', + style: TextStyle(color: Color(0xFF4FC3F7))), + ), + body: Column( + children: [ + // Header: data + filtro + Container( + padding: const EdgeInsets.all(16), + color: const Color(0xFF16213E), + child: Row( + children: [ + const Icon(Icons.calendar_today, color: Color(0xFF4FC3F7)), + const SizedBox(width: 10), + Text( + DateFormat('d MMMM yyyy', 'pt_PT').format(_selectedDate), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15), + ), + const Spacer(), + TextButton( + onPressed: _pickDate, + child: const Text('Alterar', + style: TextStyle(color: Color(0xFF4FC3F7))), + ), + ], + ), + ), + + // Lista de crianças + Expanded( + child: StreamBuilder>>( + stream: supabase.from('children').stream(primaryKey: ['id']), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator( + color: Color(0xFF4FC3F7))); + } + final children = snapshot.data!.map(Child.fromMap).toList(); + + return Column( + children: [ + // Botão marcar todos + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + child: ElevatedButton.icon( + icon: const Icon(Icons.check_circle_outline, + color: Colors.white), + label: const Text('Marcar Todos Presentes', + style: TextStyle(color: Colors.white)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFA5D6A7), + minimumSize: const Size(double.infinity, 46), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + onPressed: () => _markAllPresent(children), + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: children.length, + itemBuilder: (context, i) { + final child = children[i]; + final isPresent = _presence[child.id] ?? false; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFF16213E), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isPresent + ? const Color(0xFFA5D6A7).withOpacity(0.5) + : const Color(0xFF333366), + ), + ), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundImage: child.photoUrl != null + ? NetworkImage(child.photoUrl!) + : null, + backgroundColor: + const Color(0xFF4FC3F7).withOpacity(0.2), + child: child.photoUrl == null + ? const Icon(Icons.child_care, + color: Color(0xFF4FC3F7), size: 20) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(child.fullName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold)), + Text(child.classId, + style: const TextStyle( + color: Color(0xFF888888), + fontSize: 12)), + ], + ), + ), + Switch( + value: isPresent, + activeColor: const Color(0xFFA5D6A7), + onChanged: (v) => setState( + () => _presence[child.id] = v), + ), + ], + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + icon: _isSaving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2)) + : const Icon(Icons.save, color: Colors.white), + label: Text( + _isSaving ? 'A guardar...' : 'Guardar Presenças', + style: const TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF4FC3F7), + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + onPressed: _isSaving + ? null + : () => _saveAttendance(children), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/creche_app/lib/features/auth/invite_pending_screen.dart b/creche_app/lib/features/auth/invite_pending_screen.dart new file mode 100644 index 0000000..9f7e153 --- /dev/null +++ b/creche_app/lib/features/auth/invite_pending_screen.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '/core/auth_provider.dart'; +import '/models/invite.dart'; + +// ─── Cores ─────────────────────────────────────────────── +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); + +String _roleLabel(String r) { + switch (r) { + case 'teacher': return 'Educadora'; + case 'staff': return 'Auxiliar'; + case 'admin': return 'Administrador'; + case 'parent': return 'Encarregado de Educação'; + default: return r; + } +} + +String _roleDesc(String r) { + switch (r) { + case 'teacher': return 'Gerir turmas, registar presenças, escrever diários diários das crianças.'; + case 'staff': return 'Acesso operacional à app da creche com funcionalidades essenciais.'; + case 'admin': return 'Gestão de pagamentos, relatórios e utilizadores do sistema.'; + case 'parent': return 'Consultar o diário do seu filho, ver presenças e comunicar com a equipa.'; + default: return ''; + } +} + +IconData _roleIcon(String r) { + switch (r) { + case 'teacher': return Icons.school_outlined; + case 'staff': return Icons.cleaning_services_outlined; + case 'admin': return Icons.admin_panel_settings_outlined; + case 'parent': return Icons.family_restroom; + default: return Icons.person_outline; + } +} + +Color _roleColor(String r) { + switch (r) { + case 'teacher': return _blue; + case 'staff': return const Color(0xFFA5D6A7); + case 'admin': return const Color(0xFFFF7043); + case 'parent': return const Color(0xFFFFB300); + default: return Colors.grey; + } +} + +/// Verificar convites pendentes quando o utilizador entra pela primeira vez +class InvitePendingScreen extends ConsumerStatefulWidget { + final Invite invite; + const InvitePendingScreen({super.key, required this.invite}); + @override + ConsumerState createState() => _State(); +} + +class _State extends ConsumerState with SingleTickerProviderStateMixin { + late AnimationController _anim; + late Animation _fade; + late Animation _slide; + bool _accepting = false; + bool _rejecting = false; + + @override + void initState() { + super.initState(); + _anim = AnimationController(vsync: this, duration: const Duration(milliseconds: 700)); + _fade = CurvedAnimation(parent: _anim, curve: Curves.easeOut); + _slide = Tween(begin: const Offset(0, 0.06), end: Offset.zero) + .animate(CurvedAnimation(parent: _anim, curve: Curves.easeOut)); + _anim.forward(); + } + + @override + void dispose() { _anim.dispose(); super.dispose(); } + + Future _accept() async { + setState(() => _accepting = true); + final supabase = Supabase.instance.client; + final user = supabase.auth.currentUser!; + + try { + // 1 ─ Actualizar o convite: accepted + expirar imediatamente + await supabase.from('invites').update({ + 'status': 'accepted', + 'accepted_at': DateTime.now().toIso8601String(), + 'expires_at': DateTime.now().toIso8601String(), // expirar agora → nunca mais aparece + }).eq('id', widget.invite.id); + + // 2 ─ Verificar se já tem perfil + final existing = await supabase.from('profiles').select().eq('user_id', user.id).maybeSingle(); + + if (existing == null) { + // 3a ─ Criar o perfil com a função do convite + await supabase.from('profiles').insert({ + 'user_id': user.id, + 'full_name': user.email?.split('@').first ?? 'Utilizador', + 'role': widget.invite.role, + 'phone': widget.invite.phone, + }); + } else { + // 3b ─ Actualizar role do perfil existente + await supabase.from('profiles').update({'role': widget.invite.role}).eq('user_id', user.id); + } + + // 4 ─ Se for encarregado e tiver criança, criar ligação + if (widget.invite.role == 'parent' && widget.invite.childId != null) { + await supabase.from('child_guardians').upsert({ + 'child_id': widget.invite.childId, + 'guardian_id': (await supabase.from('profiles').select('id').eq('user_id', user.id).single())['id'], + 'relationship': 'parent', + }); + } + + // 5 ─ Invalidar o provider e navegar + ref.invalidate(currentProfileProvider); + ref.invalidate(currentSessionProvider); + + if (mounted) { + _showSnack('Bem-vindo à equipa! 🎉', success: true); + await Future.delayed(const Duration(milliseconds: 800)); + if (mounted) context.go('/home'); + } + } catch (e) { + setState(() => _accepting = false); + _showSnack('Erro ao aceitar convite: $e'); + } + } + + Future _reject() async { + setState(() => _rejecting = true); + try { + await Supabase.instance.client.from('invites').update({ + 'status': 'rejected', + 'expires_at': DateTime.now().toIso8601String(), // expirar agora + }).eq('id', widget.invite.id); + await ref.read(authNotifierProvider.notifier).signOut(); + if (mounted) context.go('/login'); + } catch (_) { + setState(() => _rejecting = false); + } + } + + void _showSnack(String msg, {bool success = false}) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg, style: const TextStyle(color: Colors.white)), + backgroundColor: success ? const Color(0xFF2ECC71) : Colors.red, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + )); + } + + @override + Widget build(BuildContext context) { + final inv = widget.invite; + final color = _roleColor(inv.role); + + return Scaffold( + backgroundColor: _bg, + body: SafeArea( + child: FadeTransition( + opacity: _fade, + child: SlideTransition( + position: _slide, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column(children: [ + const SizedBox(height: 32), + // Logo / header + Container( + width: 80, height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient(colors: [color.withOpacity(0.2), Colors.transparent]), + border: Border.all(color: color.withOpacity(0.4), width: 2), + ), + child: Icon(_roleIcon(inv.role), color: color, size: 36), + ), + const SizedBox(height: 20), + const Text('Convite Recebido 🎉', + style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('A Diretora convidou-te para a equipa', + style: TextStyle(color: Colors.white.withOpacity(0.45), fontSize: 14)), + const SizedBox(height: 32), + + // Card do convite + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: _card, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withOpacity(0.3), width: 1.5), + boxShadow: [BoxShadow(color: color.withOpacity(0.1), blurRadius: 30, offset: const Offset(0, 8))], + ), + child: Column(children: [ + // Role badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(_roleIcon(inv.role), color: color, size: 16), + const SizedBox(width: 8), + Text(_roleLabel(inv.role), + style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14, letterSpacing: 0.5)), + ]), + ), + const SizedBox(height: 20), + Text(_roleDesc(inv.role), + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14, height: 1.6)), + const SizedBox(height: 20), + const Divider(color: Color(0xFF222244)), + const SizedBox(height: 16), + // Detalhes + _DetailRow(icon: Icons.mail_outline, label: 'Email', value: inv.email), + if (inv.phone != null) ...[ + const SizedBox(height: 8), + _DetailRow(icon: Icons.phone_outlined, label: 'Telefone', value: inv.phone!), + ], + const SizedBox(height: 8), + _DetailRow( + icon: Icons.timer_outlined, + label: 'Expira em', + value: '${inv.expiresAt.difference(DateTime.now()).inDays} dias', + ), + ]), + ), + const SizedBox(height: 28), + + // Botões + _GradientBtn( + label: 'Aceitar e Entrar na Equipa', + color: color, + isLoading: _accepting, + icon: Icons.check_circle_outline, + onTap: _accept, + ), + const SizedBox(height: 12), + GestureDetector( + onTap: _rejecting ? null : _reject, + child: Container( + height: 50, width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.red.withOpacity(0.4)), + ), + child: Center(child: _rejecting + ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(color: Colors.red, strokeWidth: 2)) + : const Text('Recusar convite', style: TextStyle(color: Colors.red, fontSize: 14))), + ), + ), + const SizedBox(height: 32), + ]), + ), + ), + ), + ), + ); + } +} + +class _DetailRow extends StatelessWidget { + final IconData icon; final String label, value; + const _DetailRow({required this.icon, required this.label, required this.value}); + @override + Widget build(BuildContext context) => Row(children: [ + Icon(icon, size: 16, color: const Color(0xFF888888)), + const SizedBox(width: 8), + Text('$label: ', style: const TextStyle(color: Color(0xFF888888), fontSize: 12)), + Expanded(child: Text(value, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis)), + ]); +} + +class _GradientBtn extends StatelessWidget { + final String label; final Color color; final bool isLoading; + final IconData icon; final VoidCallback onTap; + const _GradientBtn({required this.label, required this.color, required this.isLoading, required this.icon, required this.onTap}); + @override + Widget build(BuildContext context) => GestureDetector( + onTap: isLoading ? null : onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 54, width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient(colors: isLoading ? [color.withOpacity(0.4), color.withOpacity(0.4)] : [color, color.withOpacity(0.7)]), + borderRadius: BorderRadius.circular(14), + boxShadow: isLoading ? [] : [BoxShadow(color: color.withOpacity(0.3), blurRadius: 20, offset: const Offset(0, 6))], + ), + child: Center(child: isLoading + ? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) + : Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, color: Colors.white, size: 20), + const SizedBox(width: 10), + Text(label, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)), + ])), + ), + ); +} diff --git a/creche_app/lib/features/auth/login_screen.dart b/creche_app/lib/features/auth/login_screen.dart new file mode 100644 index 0000000..c109ad3 --- /dev/null +++ b/creche_app/lib/features/auth/login_screen.dart @@ -0,0 +1,449 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '/core/auth_provider.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + final _nameCtrl = TextEditingController(); // só no registo + bool _isRegister = false; // toggle login/registo + bool _obscure = true; + bool _loading = false; + bool _biometricLoading = false; + String? _error; + late AnimationController _animCtrl; + late Animation _fadeAnim; + late Animation _slideAnim; + + @override + void initState() { + super.initState(); + _animCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 700)); + _fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut); + _slideAnim = Tween(begin: const Offset(0, 0.07), end: Offset.zero) + .animate(CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut)); + _animCtrl.forward(); + } + + @override + void dispose() { + _animCtrl.dispose(); + _emailCtrl.dispose(); + _passCtrl.dispose(); + _nameCtrl.dispose(); + super.dispose(); + } + + // ── toggle entre Login e Registo ───────────────────────────── + void _toggleMode() { + setState(() { _isRegister = !_isRegister; _error = null; }); + _animCtrl.forward(from: 0); + } + + // ── Login ───────────────────────────────────────────────────── + Future _signIn() async { + if (_emailCtrl.text.trim().isEmpty || _passCtrl.text.isEmpty) { + setState(() => _error = 'Preencha o email e a senha.'); + return; + } + setState(() { _loading = true; _error = null; }); + try { + await ref.read(authNotifierProvider.notifier).signIn( + _emailCtrl.text.trim(), _passCtrl.text, + ); + if (mounted) context.go('/home'); + } on WaitingApprovalException { + if (mounted) context.go('/waiting'); + } catch (e) { + setState(() => _error = _friendly(e.toString())); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + // ── Registo ─────────────────────────────────────────────────── + Future _signUp() async { + final email = _emailCtrl.text.trim(); + final name = _nameCtrl.text.trim(); + final pass = _passCtrl.text; + + if (email.isEmpty || pass.isEmpty || name.isEmpty) { + setState(() => _error = 'Preencha todos os campos.'); + return; + } + if (pass.length < 6) { + setState(() => _error = 'A senha deve ter pelo menos 6 caracteres.'); + return; + } + + setState(() { _loading = true; _error = null; }); + try { + final sb = Supabase.instance.client; + + // 1. Criar conta no Supabase Auth + final res = await sb.auth.signUp(email: email, password: pass); + final user = res.user; + if (user == null) throw Exception('Erro ao criar conta.'); + + // 2. Criar perfil (sem role — será atribuído pelo convite) + await sb.from('profiles').upsert({ + 'user_id': user.id, + 'full_name': name, + 'role': 'parent', // role temporário; convite vai actualizar + }); + + // 3. Ir para o splash — ele detecta convites pendentes automaticamente + if (mounted) { + _showSnack('Conta criada! A verificar convites...', ok: true); + await Future.delayed(const Duration(milliseconds: 800)); + if (mounted) context.go('/splash'); + } + } catch (e) { + setState(() => _error = _friendly(e.toString())); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + // ── Recuperar senha ─────────────────────────────────────────── + Future _forgotPassword() async { + final email = _emailCtrl.text.trim(); + if (email.isEmpty) { + setState(() => _error = 'Digite o seu email primeiro.'); + return; + } + try { + await Supabase.instance.client.auth.resetPasswordForEmail(email); + if (mounted) _showSnack('Email de recuperação enviado para $email.', ok: true); + } catch (e) { + setState(() => _error = 'Erro ao enviar email: $e'); + } + } + + // ── Biometria ──────────────────────────────────────────────── + Future _biometricSignIn() async { + if (kIsWeb) { + setState(() => _error = 'Biometria não disponível no Web.'); + return; + } + setState(() { _biometricLoading = true; _error = null; }); + try { + await ref.read(authNotifierProvider.notifier).biometricSignIn(); + if (mounted) context.go('/home'); + } on WaitingApprovalException { + if (mounted) context.go('/waiting'); + } catch (e) { + setState(() => _error = _friendly(e.toString())); + } finally { + if (mounted) setState(() => _biometricLoading = false); + } + } + + void _showSnack(String msg, {bool ok = false}) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg, style: const TextStyle(color: Colors.white)), + backgroundColor: ok ? const Color(0xFF2ECC71) : Colors.red, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + )); + } + + String _friendly(String e) { + if (e.contains('Invalid login')) return 'Email ou senha incorrectos.'; + if (e.contains('Email not confirmed')) return 'Confirme o seu email primeiro.'; + if (e.contains('already registered')) return 'Este email já tem conta. Faça login.'; + if (e.contains('Password should')) return 'A senha deve ter pelo menos 6 caracteres.'; + if (e.contains('IP')) return e; + return 'Erro. Tente novamente.'; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0D1117), + body: Stack(children: [ + Positioned(top: -80, right: -60, child: _Orb(size: 280, color: const Color(0xFF4FC3F7).withOpacity(0.11))), + Positioned(bottom: -60, left: -40, child: _Orb(size: 220, color: const Color(0xFFA5D6A7).withOpacity(0.09))), + SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: FadeTransition( + opacity: _fadeAnim, + child: SlideTransition( + position: _slideAnim, + child: Column(children: [ + const SizedBox(height: 44), + + // ── Logo ────────────────────────────────────── + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient(colors: [ + const Color(0xFF4FC3F7).withOpacity(0.18), Colors.transparent, + ]), + border: Border.all(color: const Color(0xFF4FC3F7).withOpacity(0.25), width: 1.5), + ), + child: Image.asset('assets/logo.png', height: 80, + errorBuilder: (_, __, ___) => const Icon(Icons.child_care, size: 75, color: Color(0xFF4FC3F7))), + ), + const SizedBox(height: 20), + const Text('Creche e Berçário', + style: TextStyle(color: Color(0xFF4FC3F7), fontSize: 13, letterSpacing: 2.5, fontWeight: FontWeight.w500)), + const SizedBox(height: 3), + const Text('SEMENTES DO FUTURO', + style: TextStyle(color: Colors.white, fontSize: 21, fontWeight: FontWeight.w900, letterSpacing: 1.2)), + const SizedBox(height: 5), + Text('"Conforto, cuidado e aprendizagem"', + style: TextStyle(color: Colors.white.withOpacity(0.35), fontSize: 11, fontStyle: FontStyle.italic)), + const SizedBox(height: 36), + + // ── Card principal ──────────────────────────── + Container( + padding: const EdgeInsets.all(26), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withOpacity(0.07)), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.35), blurRadius: 40, offset: const Offset(0, 16))], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + + // Título dinâmico + Text( + _isRegister ? 'Criar conta 📝' : 'Bem-vindo de volta 👋', + style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + _isRegister + ? 'Cria a tua conta — um convite vai atribuir o teu acesso' + : 'Faz login para continuar', + style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 12), + ), + const SizedBox(height: 22), + + // Erro + if (_error != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.red.withOpacity(0.3))), + child: Row(children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 16), + const SizedBox(width: 8), + Expanded(child: Text(_error!, style: const TextStyle(color: Colors.red, fontSize: 12))), + GestureDetector(onTap: () => setState(() => _error = null), + child: const Icon(Icons.close, color: Colors.red, size: 14)), + ]), + ), + const SizedBox(height: 16), + ], + + // Nome (só no registo) + if (_isRegister) ...[ + _Field(ctrl: _nameCtrl, label: 'Nome completo', icon: Icons.person_outline), + const SizedBox(height: 14), + ], + + // Email + _Field(ctrl: _emailCtrl, label: 'Email', icon: Icons.alternate_email, type: TextInputType.emailAddress), + const SizedBox(height: 14), + + // Senha + _Field( + ctrl: _passCtrl, label: 'Senha', icon: Icons.lock_outline, obscure: _obscure, + onSubmitted: (_) => _isRegister ? _signUp() : _signIn(), + suffix: IconButton( + icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility, color: Colors.white38, size: 20), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + + if (!_isRegister) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: _forgotPassword, + child: const Text('Esqueci a senha', + style: TextStyle(color: Color(0xFF4FC3F7), fontSize: 12, fontWeight: FontWeight.w500)), + ), + ), + ], + + const SizedBox(height: 24), + + // Botão principal + _GradButton( + label: _isRegister ? 'Criar Conta' : 'Entrar', + isLoading: _loading, + onTap: _isRegister ? _signUp : _signIn, + ), + + if (!_isRegister) ...[ + const SizedBox(height: 14), + Row(children: [ + Expanded(child: Divider(color: Colors.white.withOpacity(0.09))), + Padding(padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('ou', style: TextStyle(color: Colors.white.withOpacity(0.25), fontSize: 11))), + Expanded(child: Divider(color: Colors.white.withOpacity(0.09))), + ]), + const SizedBox(height: 14), + _BiometricBtn(isLoading: _biometricLoading, onTap: _biometricSignIn), + ], + ]), + ), + + const SizedBox(height: 22), + + // ── Toggle Login / Registo ───────────────────── + GestureDetector( + onTap: _toggleMode, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withOpacity(0.07)), + ), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Text( + _isRegister ? 'Já tens conta? ' : 'Ainda não tens conta? ', + style: TextStyle(color: Colors.white.withOpacity(0.45), fontSize: 13), + ), + Text( + _isRegister ? 'Fazer login' : 'Criar conta', + style: const TextStyle(color: Color(0xFF4FC3F7), fontSize: 13, fontWeight: FontWeight.bold), + ), + ]), + ), + ), + + const SizedBox(height: 20), + // Aviso do fluxo de convites + if (_isRegister) Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF4FC3F7).withOpacity(0.06), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF4FC3F7).withOpacity(0.2)), + ), + child: const Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(Icons.info_outline, color: Color(0xFF4FC3F7), size: 16), + SizedBox(width: 10), + Expanded(child: Text( + 'Após criar a conta, se tiveres um convite da Diretora, verás automaticamente o ecrã de convite para aceitar o teu acesso.', + style: TextStyle(color: Color(0xFF888888), fontSize: 11, height: 1.5), + )), + ]), + ), + + const SizedBox(height: 20), + Text('v1.0 · Diário do Candengue', + style: TextStyle(color: Colors.white.withOpacity(0.15), fontSize: 10)), + const SizedBox(height: 24), + ]), + ), + ), + ), + ), + ]), + ); + } +} + +// ── Widgets auxiliares ───────────────────────────────────────────── + +class _Field extends StatelessWidget { + final TextEditingController ctrl; + final String label; + final IconData icon; + final bool obscure; + final TextInputType type; + final Widget? suffix; + final Function(String)? onSubmitted; + const _Field({required this.ctrl, required this.label, required this.icon, + this.obscure = false, this.type = TextInputType.text, this.suffix, this.onSubmitted}); + @override + Widget build(BuildContext context) => TextField( + controller: ctrl, obscureText: obscure, keyboardType: type, onSubmitted: onSubmitted, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 13), + prefixIcon: Icon(icon, color: const Color(0xFF4FC3F7).withOpacity(0.7), size: 19), + suffixIcon: suffix, + filled: true, fillColor: Colors.white.withOpacity(0.05), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.white.withOpacity(0.1))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF4FC3F7), width: 1.5)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + ), + ); +} + +class _GradButton extends StatelessWidget { + final String label; final bool isLoading; final VoidCallback onTap; + const _GradButton({required this.label, required this.isLoading, required this.onTap}); + @override + Widget build(BuildContext context) => GestureDetector( + onTap: isLoading ? null : onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), height: 52, + decoration: BoxDecoration( + gradient: LinearGradient(colors: isLoading + ? [const Color(0xFF2A6A8A), const Color(0xFF2A6A8A)] + : [const Color(0xFF4FC3F7), const Color(0xFF0288D1)]), + borderRadius: BorderRadius.circular(14), + boxShadow: isLoading ? [] : [BoxShadow(color: const Color(0xFF4FC3F7).withOpacity(0.3), blurRadius: 18, offset: const Offset(0, 6))], + ), + child: Center(child: isLoading + ? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) + : Text(label, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold))), + ), + ); +} + +class _BiometricBtn extends StatelessWidget { + final bool isLoading; final VoidCallback onTap; + const _BiometricBtn({required this.isLoading, required this.onTap}); + @override + Widget build(BuildContext context) => GestureDetector( + onTap: isLoading ? null : onTap, + child: Container( + height: 50, + decoration: BoxDecoration(color: Colors.white.withOpacity(0.04), borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withOpacity(0.1))), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + isLoading + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Color(0xFF4FC3F7), strokeWidth: 2)) + : const Icon(Icons.fingerprint, color: Color(0xFF4FC3F7), size: 22), + const SizedBox(width: 10), + Text(kIsWeb ? 'Biometria (só mobile)' : 'Entrar com biometria', + style: TextStyle(color: kIsWeb ? Colors.white24 : Colors.white60, fontSize: 13)), + ]), + ), + ); +} + +class _Orb extends StatelessWidget { + final double size; final Color color; + const _Orb({required this.size, required this.color}); + @override + Widget build(BuildContext context) => Container( + width: size, height: size, + decoration: BoxDecoration(shape: BoxShape.circle, color: color, + boxShadow: [BoxShadow(color: color, blurRadius: size / 2)]), + ); +} diff --git a/creche_app/lib/features/auth/waiting_approval_screen.dart b/creche_app/lib/features/auth/waiting_approval_screen.dart new file mode 100644 index 0000000..96e32d4 --- /dev/null +++ b/creche_app/lib/features/auth/waiting_approval_screen.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:go_router/go_router.dart'; +import '/core/auth_provider.dart'; +import '/models/daily_access_approval.dart'; + +class WaitingApprovalScreen extends ConsumerStatefulWidget { + const WaitingApprovalScreen({super.key}); + @override + ConsumerState createState() => _State(); +} + +class _State extends ConsumerState { + String? _profileId; + bool _requesting = false; + + @override + void initState() { + super.initState(); + _loadProfileAndRequest(); + } + + Future _loadProfileAndRequest() async { + final sb = Supabase.instance.client; + final uid = sb.auth.currentUser?.id; + if (uid == null) return; + + // Buscar profiles.id (NÃO auth.uid!) + final profile = await sb.from('profiles').select('id').eq('user_id', uid).maybeSingle(); + if (profile == null || !mounted) return; + + final profileId = profile['id'] as String; + setState(() => _profileId = profileId); + + // Criar pedido de aprovação se não existir hoje + final today = DateTime.now().toIso8601String().split('T')[0]; + final existing = await sb.from('daily_access_approvals') + .select().eq('user_id', profileId).eq('approval_date', today).maybeSingle(); + + if (existing == null && mounted) { + setState(() => _requesting = true); + try { + await sb.from('daily_access_approvals').insert({ + 'user_id': profileId, + 'approval_date': today, + 'status': 'pending', + }); + } catch (_) {} + if (mounted) setState(() => _requesting = false); + } + } + + @override + Widget build(BuildContext context) { + final sb = Supabase.instance.client; + final today = DateTime.now().toIso8601String().split('T')[0]; + + return Scaffold( + backgroundColor: const Color(0xFF0D1117), + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Image.asset('assets/logo.png', height: 72, + errorBuilder: (_, __, ___) => + const Icon(Icons.child_care, size: 72, color: Color(0xFF4FC3F7))), + const SizedBox(height: 24), + Lottie.asset('assets/waiting.json', height: 160, + errorBuilder: (_, __, ___) => + const Icon(Icons.hourglass_top, size: 80, color: Color(0xFF4FC3F7))), + const SizedBox(height: 20), + const Text('Sala de Espera', + style: TextStyle(color: Color(0xFF4FC3F7), fontSize: 24, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('Aguardando aprovação da Diretora', + style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center), + const SizedBox(height: 6), + Text('O teu acesso de hoje será aprovado em breve.', + style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 12), + textAlign: TextAlign.center), + const SizedBox(height: 32), + + if (_requesting) + const CircularProgressIndicator(color: Color(0xFF4FC3F7)) + else if (_profileId != null) + StreamBuilder>>( + stream: sb.from('daily_access_approvals') + .stream(primaryKey: ['id']) + .eq('user_id', _profileId!) // usa profiles.id CORRECTO + .order('created_at', ascending: false), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const CircularProgressIndicator(color: Color(0xFF4FC3F7)); + } + final todayRows = snapshot.data! + .where((r) => r['approval_date'] == today).toList(); + + if (todayRows.isEmpty) { + return Column(children: [ + const CircularProgressIndicator(color: Color(0xFF4FC3F7)), + const SizedBox(height: 12), + Text('A criar pedido...', style: TextStyle(color: Colors.white.withOpacity(0.4))), + ]); + } + + final approval = DailyAccessApproval.fromMap(todayRows.first); + + if (approval.status == 'approved') { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.go('/home'); + }); + return const Column(children: [ + Icon(Icons.check_circle, color: Color(0xFF2ECC71), size: 48), + SizedBox(height: 8), + Text('Aprovado! A redirecionar...', style: TextStyle(color: Color(0xFF2ECC71))), + ]); + } + + if (approval.status == 'rejected') { + return Column(children: [ + const Icon(Icons.block, color: Colors.red, size: 48), + const SizedBox(height: 8), + const Text('Acesso negado hoje', + style: TextStyle(color: Colors.red, fontSize: 17, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text('Contacta a Diretora para mais informações.', + style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 12), + textAlign: TextAlign.center), + ]); + } + + return Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + const SizedBox(width: 20, height: 20, + child: CircularProgressIndicator(color: Color(0xFF4FC3F7), strokeWidth: 2)), + const SizedBox(width: 12), + Text('Pendente... aguarda', + style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 13)), + ]); + }, + ) + else + const CircularProgressIndicator(color: Color(0xFF4FC3F7)), + + const SizedBox(height: 36), + OutlinedButton.icon( + icon: const Icon(Icons.logout, color: Colors.red, size: 18), + label: const Text('Terminar Sessão', style: TextStyle(color: Colors.red)), + onPressed: () async { + await ref.read(authNotifierProvider.notifier).signOut(); + if (context.mounted) context.go('/login'); + }, + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 46), + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ]), + ), + ), + ), + ); + } +} diff --git a/creche_app/lib/features/chat/chat_list_screen.dart b/creche_app/lib/features/chat/chat_list_screen.dart new file mode 100644 index 0000000..21960e1 --- /dev/null +++ b/creche_app/lib/features/chat/chat_list_screen.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '/core/auth_provider.dart'; +import '/models/profile.dart'; + +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); + +String _roleLabel(String r) { + switch (r) { + case 'principal': return 'Diretora'; + case 'admin': return 'Administrador'; + case 'teacher': return 'Educadora'; + case 'staff': return 'Auxiliar'; + case 'parent': return 'Encarregado'; + default: return r; + } +} + +class ChatListScreen extends ConsumerWidget { + const ChatListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sb = Supabase.instance.client; + final profileAsync = ref.watch(currentProfileProvider); + + return profileAsync.when( + data: (myProfile) { + if (myProfile == null) { + return const Scaffold( + backgroundColor: _bg, + body: Center(child: Text('Perfil não encontrado', style: TextStyle(color: Colors.white))), + ); + } + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, + title: const Text('Mensagens', style: TextStyle(color: _blue, fontWeight: FontWeight.bold)), + elevation: 0, + ), + body: StreamBuilder>>( + stream: sb.from('profiles').stream(primaryKey: ['id']), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator(color: _blue)); + } + final profiles = snapshot.data! + .map(Profile.fromMap) + .where((p) => p.id != myProfile.id) + .toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName)); + + if (profiles.isEmpty) { + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.chat_bubble_outline, size: 64, color: Colors.white.withOpacity(0.07)), + const SizedBox(height: 12), + const Text('Sem utilizadores para contactar', + style: TextStyle(color: Color(0xFF888888))), + ])); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: profiles.length, + separatorBuilder: (_, __) => Divider( + height: 1, indent: 72, + color: Colors.white.withOpacity(0.05), + ), + itemBuilder: (context, i) { + final profile = profiles[i]; + return _ChatTile( + profile: profile, + myProfileId: myProfile.id, + onTap: () => context.go('/chat/${profile.id}'), + ); + }, + ); + }, + ), + ); + }, + loading: () => const Scaffold( + backgroundColor: _bg, + body: Center(child: CircularProgressIndicator(color: _blue)), + ), + error: (e, _) => Scaffold( + backgroundColor: _bg, + body: Center(child: Text('Erro: $e', style: const TextStyle(color: Colors.red))), + ), + ); + } +} + +class _ChatTile extends StatelessWidget { + final Profile profile; + final String myProfileId; + final VoidCallback onTap; + const _ChatTile({required this.profile, required this.myProfileId, required this.onTap}); + + @override + Widget build(BuildContext context) { + final sb = Supabase.instance.client; + return FutureBuilder?>( + future: _getLastMessage(sb), + builder: (context, snap) { + final lastMsg = snap.data?['content'] as String? ?? ''; + final unread = snap.data?['unread'] as int? ?? 0; + final hasUnread = unread > 0; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + splashColor: _blue.withOpacity(0.08), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row(children: [ + // Avatar + CircleAvatar( + radius: 26, + backgroundColor: _blue.withOpacity(0.12), + backgroundImage: profile.avatarUrl != null + ? NetworkImage(profile.avatarUrl!) : null, + child: profile.avatarUrl == null + ? Text(profile.fullName.isNotEmpty ? profile.fullName[0].toUpperCase() : '?', + style: const TextStyle(color: _blue, fontWeight: FontWeight.bold, fontSize: 18)) + : null, + ), + const SizedBox(width: 14), + // Info + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(profile.fullName, + style: TextStyle( + color: Colors.white, + fontWeight: hasUnread ? FontWeight.bold : FontWeight.w500, + fontSize: 14, + )), + const SizedBox(height: 2), + Text(lastMsg.isNotEmpty ? lastMsg : _roleLabel(profile.role), + maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle( + color: hasUnread ? _blue : const Color(0xFF888888), + fontSize: 12, + fontWeight: hasUnread ? FontWeight.w600 : FontWeight.normal, + )), + ])), + // Unread badge + if (hasUnread) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: _blue, borderRadius: BorderRadius.circular(12), + ), + child: Text('$unread', + style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)), + ) + else + const Icon(Icons.chevron_right, color: Color(0xFF444466), size: 20), + ]), + ), + ), + ); + }, + ); + } + +Future?> _getLastMessage(SupabaseClient sb) async { + try { + final msgs = await sb + .from('messages') + .select('content, from_user, is_read') + .or( + 'and(from_user.eq.$myProfileId,to_user.eq.${profile.id}),' + 'and(from_user.eq.${profile.id},to_user.eq.$myProfileId)' + ) + .order('created_at', ascending: false) + .limit(1); + + if (msgs.isEmpty) return null; + + final last = msgs.first; + + final unreadMsgs = await sb + .from('messages') + .select('id') + .eq('from_user', profile.id) + .eq('to_user', myProfileId) + .eq('is_read', false); + + return { + 'content': last['content'] as String, + 'unread': unreadMsgs.length, + }; + } catch (_) { + return null; + } +} +} diff --git a/creche_app/lib/features/chat/chat_screen.dart b/creche_app/lib/features/chat/chat_screen.dart new file mode 100644 index 0000000..f943bfc --- /dev/null +++ b/creche_app/lib/features/chat/chat_screen.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/intl.dart'; +import '/core/auth_provider.dart'; +import '/models/message.dart'; +import '/models/profile.dart'; + +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); + +class ChatScreen extends ConsumerStatefulWidget { + final String toUserId; // profiles.id (não auth uid) + const ChatScreen({super.key, required this.toUserId}); + @override + ConsumerState createState() => _ChatScreenState(); +} + +class _ChatScreenState extends ConsumerState { + final _msgCtrl = TextEditingController(); + final _scrollCtrl = ScrollController(); + String? _myProfileId; + Profile? _otherProfile; + bool _loadingProfile = true; + + @override + void initState() { + super.initState(); + _loadProfiles(); + } + + @override + void dispose() { + _msgCtrl.dispose(); + _scrollCtrl.dispose(); + super.dispose(); + } + + Future _loadProfiles() async { + final sb = Supabase.instance.client; + final uid = sb.auth.currentUser?.id; + if (uid == null) return; + + // Buscar o meu profile id + final me = await sb.from('profiles').select('id').eq('user_id', uid).maybeSingle(); + // Buscar perfil do outro + final other = await sb.from('profiles').select().eq('id', widget.toUserId).maybeSingle(); + + if (mounted) { + setState(() { + _myProfileId = me?['id'] as String?; + _otherProfile = other != null ? Profile.fromMap(other) : null; + _loadingProfile = false; + }); + } + _scrollToBottom(); + } + + Future _send() async { + final text = _msgCtrl.text.trim(); + if (text.isEmpty || _myProfileId == null) return; + final sb = Supabase.instance.client; + _msgCtrl.clear(); + try { + await sb.from('messages').insert({ + 'from_user': _myProfileId, + 'to_user': widget.toUserId, + 'content': text, + 'is_read': false, + }); + _scrollToBottom(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Erro ao enviar: $e'), + backgroundColor: Colors.red, behavior: SnackBarBehavior.floating)); + } + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollCtrl.hasClients) { + _scrollCtrl.animateTo(_scrollCtrl.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + } + }); + } + + @override + Widget build(BuildContext context) { + if (_loadingProfile) { + return const Scaffold(backgroundColor: _bg, + body: Center(child: CircularProgressIndicator(color: _blue))); + } + if (_myProfileId == null) { + return const Scaffold(backgroundColor: _bg, + body: Center(child: Text('Perfil não encontrado.', style: TextStyle(color: Colors.white)))); + } + + final sb = Supabase.instance.client; + final myId = _myProfileId!; + + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, + elevation: 0, + title: Row(children: [ + CircleAvatar( + radius: 18, + backgroundColor: _blue.withOpacity(0.15), + backgroundImage: _otherProfile?.avatarUrl != null + ? NetworkImage(_otherProfile!.avatarUrl!) : null, + child: _otherProfile?.avatarUrl == null + ? Text((_otherProfile?.fullName ?? '?')[0].toUpperCase(), + style: const TextStyle(color: _blue, fontWeight: FontWeight.bold)) : null, + ), + const SizedBox(width: 10), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_otherProfile?.fullName ?? 'Utilizador', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold)), + if (_otherProfile?.role != null) + Text(_otherProfile!.role, style: const TextStyle(color: Color(0xFF888888), fontSize: 11)), + ]), + ]), + ), + body: Column(children: [ + Expanded( + child: StreamBuilder>>( + stream: sb.from('messages').stream(primaryKey: ['id']).order('created_at'), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Erro: ${snapshot.error}', + style: const TextStyle(color: Colors.red))); + } + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator(color: _blue)); + } + + final msgs = snapshot.data! + .where((m) => + (m['from_user'] == myId && m['to_user'] == widget.toUserId) || + (m['from_user'] == widget.toUserId && m['to_user'] == myId)) + .map(Message.fromMap) + .toList(); + + if (msgs.isEmpty) { + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.chat_bubble_outline, color: Colors.white.withOpacity(0.15), size: 60), + const SizedBox(height: 12), + Text('Começa a conversa!', + style: TextStyle(color: Colors.white.withOpacity(0.3), fontSize: 14)), + ])); + } + + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); + + return ListView.builder( + controller: _scrollCtrl, + padding: const EdgeInsets.all(16), + itemCount: msgs.length, + itemBuilder: (_, i) { + final msg = msgs[i]; + final isMe = msg.fromUser == myId; + return _Bubble(msg: msg, isMe: isMe); + }, + ); + }, + ), + ), + // Input + Container( + color: _card, + padding: EdgeInsets.only( + left: 12, right: 12, top: 10, + bottom: MediaQuery.of(context).viewInsets.bottom + 10, + ), + child: Row(children: [ + Expanded( + child: TextField( + controller: _msgCtrl, + style: const TextStyle(color: Colors.white, fontSize: 14), + maxLines: 4, minLines: 1, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _send(), + decoration: InputDecoration( + hintText: 'Escreve uma mensagem...', + hintStyle: TextStyle(color: Colors.white.withOpacity(0.3), fontSize: 13), + filled: true, fillColor: Colors.white.withOpacity(0.05), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none), + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: _send, + child: Container( + width: 44, height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [_blue, Color(0xFF0288D1)]), + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: _blue.withOpacity(0.3), blurRadius: 10)], + ), + child: const Icon(Icons.send_rounded, color: Colors.white, size: 20), + ), + ), + ]), + ), + ]), + ); + } +} + +class _Bubble extends StatelessWidget { + final Message msg; + final bool isMe; + const _Bubble({required this.msg, required this.isMe}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe) ...[ + CircleAvatar(radius: 14, backgroundColor: _blue.withOpacity(0.15), + child: const Icon(Icons.person, color: _blue, size: 14)), + const SizedBox(width: 6), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isMe ? const Color(0xFF1A4A6A) : _card, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isMe ? 16 : 4), + bottomRight: Radius.circular(isMe ? 4 : 16), + ), + border: Border.all( + color: isMe ? _blue.withOpacity(0.3) : Colors.white.withOpacity(0.07)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(msg.content, + style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4)), + const SizedBox(height: 4), + Text( + DateFormat('HH:mm').format(msg.createdAt.toLocal()), + style: TextStyle(color: Colors.white.withOpacity(0.35), fontSize: 10), + ), + ]), + ), + ), + if (isMe) const SizedBox(width: 4), + ], + ), + ); + } +} diff --git a/creche_app/lib/features/children/child_detail_screen.dart b/creche_app/lib/features/children/child_detail_screen.dart new file mode 100644 index 0000000..6af518a --- /dev/null +++ b/creche_app/lib/features/children/child_detail_screen.dart @@ -0,0 +1,592 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; +import 'package:go_router/go_router.dart'; +import '/core/supabase_client.dart'; +import '/models/child.dart'; +import '/models/profile.dart'; +import '/shared/widgets/custom_button.dart'; +import '/core/auth_provider.dart'; + +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); +const _green = Color(0xFF2ECC71); +const _red = Color(0xFFE74C3C); +const _amber = Color(0xFFFFB300); + +class ChildDetailScreen extends ConsumerStatefulWidget { + final String id; + const ChildDetailScreen({super.key, required this.id}); + @override + ConsumerState createState() => _State(); +} + +class _State extends ConsumerState with SingleTickerProviderStateMixin { + late TabController _tabs; + Child? _child; + bool _loading = true; + bool _isNew = false; + bool _saving = false; + final _formKey = GlobalKey(); + + // Profile tab + final _firstCtrl = TextEditingController(); + final _lastCtrl = TextEditingController(); + DateTime _birth = DateTime.now().subtract(const Duration(days: 730)); + String? _photoUrl; + String? _classId; + String? _teacherId; + String? _roomId; + + // Health tab + final _allergyCtrl = TextEditingController(); + final _foodRestCtrl = TextEditingController(); + final _medicalNotesCtrl = TextEditingController(); + final List _allergyList = []; + final List _foodRestList = []; + + // Dropdown data from DB + List> _rooms = []; + List _teachers = []; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 4, vsync: this); + _isNew = widget.id == 'new'; + _loadDropdowns(); + if (!_isNew) _loadChild(); else setState(() => _loading = false); + } + + @override + void dispose() { + _tabs.dispose(); + _firstCtrl.dispose(); _lastCtrl.dispose(); + _allergyCtrl.dispose(); _foodRestCtrl.dispose(); _medicalNotesCtrl.dispose(); + super.dispose(); + } + + Future _loadDropdowns() async { + final sb = ref.read(supabaseProvider); + try { + final rooms = await sb.from('rooms').select().order('name'); + final teachers = await sb.from('profiles').select().inFilter('role', ['teacher','staff']).order('full_name'); + if (mounted) setState(() { + _rooms = List>.from(rooms); + _teachers = teachers.map((t) => Profile.fromMap(t)).toList(); + }); + } catch (_) {} + } + + Future _loadChild() async { + final sb = ref.read(supabaseProvider); + try { + final data = await sb.from('children').select().eq('id', widget.id).single(); + final child = Child.fromMap(data); + setState(() { + _child = child; + _firstCtrl.text = child.firstName; + _lastCtrl.text = child.lastName; + _birth = child.birthDate; + _photoUrl = child.photoUrl; + _classId = child.classId.isEmpty ? null : child.classId; + _teacherId = child.teacherId.isEmpty ? null : child.teacherId; + _roomId = child.roomId; + // Parse allergies + if (child.allergies != null && child.allergies!.isNotEmpty) { + _allergyList.addAll(child.allergies!.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty)); + } + if (child.foodRestrictions != null && child.foodRestrictions!.isNotEmpty) { + _foodRestList.addAll(child.foodRestrictions!.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty)); + } + _loading = false; + }); + } catch (e) { + if (mounted) { _snack('Erro ao carregar: $e'); setState(() => _loading = false); } + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + final sb = ref.read(supabaseProvider); + try { + final data = { + 'first_name': _firstCtrl.text.trim(), + 'last_name': _lastCtrl.text.trim(), + 'birth_date': _birth.toIso8601String().split('T')[0], + 'photo_url': _photoUrl, + 'class_id': _classId ?? '', + 'teacher_id': _teacherId ?? '', + 'room_id': _roomId, + 'status': 'active', + 'allergies': _allergyList.join(', '), + 'food_restrictions': _foodRestList.join(', '), + }; + if (_isNew) { + await sb.from('children').insert(data); + } else { + await sb.from('children').update(data).eq('id', widget.id); + } + if (mounted) { _snack('Guardado! ✓', ok: true); await Future.delayed(const Duration(milliseconds: 500)); if (mounted) context.go('/children'); } + } catch (e) { if (mounted) _snack('Erro: $e'); } + finally { if (mounted) setState(() => _saving = false); } + } + + Future _pickPhoto() async { + final img = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 70); + if (img == null) return; + final sb = ref.read(supabaseProvider); + final bytes = await img.readAsBytes(); + final path = 'children/${const Uuid().v4()}.jpg'; + await sb.storage.from('photos').uploadBinary(path, bytes); + final url = sb.storage.from('photos').getPublicUrl(path); + setState(() => _photoUrl = url); + } + + void _snack(String msg, {bool ok = false}) => ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg, style: const TextStyle(color: Colors.white)), + backgroundColor: ok ? _green : _red, behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)))); + + @override + Widget build(BuildContext context) { + if (_loading) return const Scaffold(backgroundColor: _bg, body: Center(child: CircularProgressIndicator(color: _blue))); + + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, elevation: 0, + title: Text(_isNew ? 'Nova Criança' : (_child?.fullName ?? 'Criança'), + style: const TextStyle(color: _blue, fontWeight: FontWeight.bold)), + bottom: _isNew ? null : TabBar( + controller: _tabs, indicatorColor: _blue, labelColor: _blue, + unselectedLabelColor: Colors.white38, + isScrollable: true, tabAlignment: TabAlignment.start, + tabs: const [Tab(text: 'Perfil'), Tab(text: 'Saúde'), Tab(text: 'Diário'), Tab(text: 'Presença')], + ), + ), + body: _isNew + ? Form(key: _formKey, child: _buildProfileForm()) + : TabBarView(controller: _tabs, children: [ + Form(key: _formKey, child: _buildProfileForm()), + _buildHealthTab(), + _buildDiaryTab(), + _buildAttendanceTab(), + ]), + ); + } + + // ── ABA PERFIL ───────────────────────────────────────────────── + Widget _buildProfileForm() => SingleChildScrollView( + padding: const EdgeInsets.all(18), + child: Column(children: [ + // Foto + Center(child: Stack(children: [ + Container(width: 100, height: 100, + decoration: BoxDecoration(shape: BoxShape.circle, + border: Border.all(color: _blue.withOpacity(0.3), width: 2), + color: _blue.withOpacity(0.08)), + child: _photoUrl != null + ? ClipOval(child: Image.network(_photoUrl!, fit: BoxFit.cover)) + : const Icon(Icons.child_care, size: 50, color: _blue), + ), + Positioned(bottom: 0, right: 0, child: GestureDetector( + onTap: _pickPhoto, + child: Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration(color: _blue, shape: BoxShape.circle, + border: Border.all(color: _bg, width: 2)), + child: const Icon(Icons.camera_alt, color: Colors.white, size: 15), + ), + )), + ])), + const SizedBox(height: 22), + _field(_firstCtrl, 'Nome', Icons.person_outline, req: true), + const SizedBox(height: 12), + _field(_lastCtrl, 'Sobrenome', Icons.person, req: true), + const SizedBox(height: 12), + // Data de nascimento + GestureDetector( + onTap: () async { + final d = await showDatePicker(context: context, + initialDate: _birth, firstDate: DateTime(2015), lastDate: DateTime.now(), + builder: (ctx, child) => Theme( + data: ThemeData.dark().copyWith(colorScheme: const ColorScheme.dark(primary: _blue)), + child: child!)); + if (d != null) setState(() => _birth = d); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration(color: Colors.white.withOpacity(0.04), borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.09))), + child: Row(children: [ + Icon(Icons.cake, color: _blue.withOpacity(0.7), size: 19), + const SizedBox(width: 12), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Data de Nascimento', style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 11)), + Text(DateFormat('dd/MM/yyyy').format(_birth), + style: const TextStyle(color: Colors.white, fontSize: 14)), + ]), + const Spacer(), + Icon(Icons.edit_calendar, color: Colors.white.withOpacity(0.2), size: 16), + ]), + ), + ), + const SizedBox(height: 12), + // Sala (do DB) + _dropdown( + value: _roomId, + hint: 'Seleccionar Sala', + icon: Icons.meeting_room_outlined, + items: _rooms.map((r) => DropdownMenuItem( + value: r['id'] as String, + child: Text(r['name'] ?? '', style: const TextStyle(color: Colors.white)))).toList(), + onChanged: (v) => setState(() => _roomId = v), + ), + const SizedBox(height: 12), + // Educadora (do DB) + _dropdown( + value: _teacherId, + hint: 'Seleccionar Educadora', + icon: Icons.supervisor_account_outlined, + items: _teachers.map((t) => DropdownMenuItem( + value: t.id, + child: Text(t.fullName, style: const TextStyle(color: Colors.white)))).toList(), + onChanged: (v) => setState(() => _teacherId = v), + ), + const SizedBox(height: 28), + CustomButton(text: _isNew ? 'Criar Criança' : 'Guardar', isLoading: _saving, + onPressed: _save, icon: Icons.save_outlined), + const SizedBox(height: 20), + ]), + ); + + // ── ABA SAÚDE ────────────────────────────────────────────────── + Widget _buildHealthTab() => SingleChildScrollView( + padding: const EdgeInsets.all(18), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + + _healthCard( + title: '⚠️ Alergias', + color: _red, + chips: _allergyList, + ctrl: _allergyCtrl, + hint: 'Ex: Amendoim, Leite, Glúten...', + onAdd: () { if (_allergyCtrl.text.trim().isNotEmpty) { + setState(() { _allergyList.add(_allergyCtrl.text.trim()); _allergyCtrl.clear(); }); + _saveHealthData(); + }}, + onRemove: (i) { setState(() => _allergyList.removeAt(i)); _saveHealthData(); }, + ), + const SizedBox(height: 14), + + _healthCard( + title: '🚫 Alimentos Não Permitidos', + color: _amber, + chips: _foodRestList, + ctrl: _foodRestCtrl, + hint: 'Ex: Carne de porco, frutos do mar...', + onAdd: () { if (_foodRestCtrl.text.trim().isNotEmpty) { + setState(() { _foodRestList.add(_foodRestCtrl.text.trim()); _foodRestCtrl.clear(); }); + _saveHealthData(); + }}, + onRemove: (i) { setState(() => _foodRestList.removeAt(i)); _saveHealthData(); }, + ), + const SizedBox(height: 14), + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withOpacity(0.07))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('📋 Observações Médicas', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 10), + TextField(controller: _medicalNotesCtrl, maxLines: 4, + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: InputDecoration( + hintText: 'Condições médicas, medicação habitual, contacto de emergência...', + hintStyle: const TextStyle(color: Color(0xFF555555), fontSize: 12), + filled: true, fillColor: Colors.white.withOpacity(0.04), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09))), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + )), + const SizedBox(height: 10), + CustomButton(text: 'Guardar Observações', onPressed: _saveHealthData, icon: Icons.save_outlined), + ]), + ), + + const SizedBox(height: 14), + // Link para medicação + GestureDetector( + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (_) => _MedQuickView(childId: widget.id, childName: _child?.fullName ?? ''))), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _amber.withOpacity(0.07), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _amber.withOpacity(0.3)), + ), + child: const Row(children: [ + Icon(Icons.medication, color: _amber), + SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Medicação', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + Text('Ver / gerir medicação activa desta criança', style: TextStyle(color: Color(0xFF888888), fontSize: 11)), + ])), + Icon(Icons.chevron_right, color: _amber), + ]), + ), + ), + ]), + ); + + Future _saveHealthData() async { + if (_isNew || widget.id.isEmpty) return; + final sb = ref.read(supabaseProvider); + try { + await sb.from('children').update({ + 'allergies': _allergyList.join(', '), + 'food_restrictions': _foodRestList.join(', '), + }).eq('id', widget.id); + } catch (_) {} + } + + Widget _healthCard({ + required String title, required Color color, + required List chips, required TextEditingController ctrl, + required String hint, required VoidCallback onAdd, required Function(int) onRemove, + }) => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), + border: Border.all(color: color.withOpacity(0.2))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 10), + if (chips.isEmpty) + Text('Nenhum registo', style: TextStyle(color: Colors.white.withOpacity(0.25), fontSize: 12)) + else + Wrap(spacing: 6, runSpacing: 4, children: chips.asMap().entries.map((e) => + Chip( + label: Text(e.value, style: const TextStyle(color: Colors.white, fontSize: 12)), + backgroundColor: color.withOpacity(0.12), + deleteIconColor: color.withOpacity(0.6), + side: BorderSide(color: color.withOpacity(0.3)), + onDeleted: () => onRemove(e.key), + )).toList()), + const SizedBox(height: 10), + Row(children: [ + Expanded(child: TextField( + controller: ctrl, + style: const TextStyle(color: Colors.white, fontSize: 13), + onSubmitted: (_) => onAdd(), + decoration: InputDecoration( + hintText: hint, hintStyle: const TextStyle(color: Color(0xFF555555), fontSize: 12), + filled: true, fillColor: Colors.white.withOpacity(0.04), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09))), + ), + )), + const SizedBox(width: 8), + GestureDetector( + onTap: onAdd, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: color.withOpacity(0.15), shape: BoxShape.circle, + border: Border.all(color: color.withOpacity(0.3))), + child: Icon(Icons.add, color: color, size: 18), + ), + ), + ]), + ]), + ); + + // ── ABA DIÁRIO ───────────────────────────────────────────────── + Widget _buildDiaryTab() { + final sb = ref.read(supabaseProvider); + return FutureBuilder>>( + future: sb.from('daily_diaries').select() + .eq('child_id', widget.id).order('date', ascending: false).limit(20), + builder: (ctx, snap) { + if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: _blue)); + if (snap.data!.isEmpty) return _empty('Sem entradas no diário'); + return ListView.builder( + padding: const EdgeInsets.all(14), + itemCount: snap.data!.length, + itemBuilder: (_, i) { + final d = snap.data![i]; + final date = DateTime.tryParse(d['date'] ?? '') ?? DateTime.now(); + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withOpacity(0.07))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(Icons.book_outlined, color: _blue, size: 16), + const SizedBox(width: 6), + Text(DateFormat('EEEE, d MMM yyyy', 'pt_PT').format(date), + style: const TextStyle(color: _blue, fontWeight: FontWeight.bold, fontSize: 13)), + ]), + if ((d['activities'] ?? '').isNotEmpty) ...[ + const SizedBox(height: 6), + Text(d['activities'], style: const TextStyle(color: Colors.white70, fontSize: 13)), + ], + if ((d['institution_notes'] ?? '').isNotEmpty) ...[ + const SizedBox(height: 6), + Row(children: [ + const Icon(Icons.business_outlined, size: 12, color: _amber), + const SizedBox(width: 4), + Expanded(child: Text(d['institution_notes'], + style: const TextStyle(color: _amber, fontSize: 12))), + ]), + ], + ]), + ); + }, + ); + }, + ); + } + + // ── ABA PRESENÇA ─────────────────────────────────────────────── + Widget _buildAttendanceTab() { + final sb = ref.read(supabaseProvider); + return FutureBuilder>>( + future: sb.from('attendance').select() + .eq('child_id', widget.id).order('date', ascending: false).limit(30), + builder: (ctx, snap) { + if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: _blue)); + if (snap.data!.isEmpty) return _empty('Sem registos de presença'); + return ListView.builder( + padding: const EdgeInsets.all(14), + itemCount: snap.data!.length, + itemBuilder: (_, i) { + final a = snap.data![i]; + final present = a['present'] as bool? ?? false; + final date = DateTime.tryParse(a['date'] ?? '') ?? DateTime.now(); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(12), + border: Border.all(color: present ? _green.withOpacity(0.2) : _red.withOpacity(0.2))), + child: Row(children: [ + Icon(present ? Icons.check_circle : Icons.cancel, + color: present ? _green : _red, size: 20), + const SizedBox(width: 10), + Text(DateFormat('EEEE, d/MM/yyyy', 'pt_PT').format(date), + style: const TextStyle(color: Colors.white, fontSize: 13)), + const Spacer(), + Text(present ? 'Presente' : 'Ausente', + style: TextStyle(color: present ? _green : _red, fontSize: 12)), + ]), + ); + }, + ); + }, + ); + } + + // ── HELPERS ──────────────────────────────────────────────────── + Widget _field(TextEditingController c, String label, IconData icon, {bool req = false}) => TextFormField( + controller: c, + style: const TextStyle(color: Colors.white, fontSize: 14), + validator: req ? (v) => (v?.trim().isEmpty ?? true) ? 'Obrigatório' : null : null, + decoration: InputDecoration( + labelText: label, labelStyle: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 13), + prefixIcon: Icon(icon, color: _blue.withOpacity(0.7), size: 19), + filled: true, fillColor: Colors.white.withOpacity(0.04), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _blue, width: 1.5)), + errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _red)), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + ), + ); + + Widget _dropdown({T? value, required String hint, required IconData icon, + required List> items, required ValueChanged onChanged}) => + DropdownButtonFormField( + value: value, dropdownColor: _card, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + hintText: hint, hintStyle: TextStyle(color: Colors.white.withOpacity(0.3), fontSize: 13), + prefixIcon: Icon(icon, color: _blue.withOpacity(0.7), size: 19), + filled: true, fillColor: Colors.white.withOpacity(0.04), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09))), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + ), + items: items, + onChanged: onChanged, + ); + + Widget _empty(String msg) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.info_outline, size: 48, color: Colors.white.withOpacity(0.1)), + const SizedBox(height: 10), + Text(msg, style: const TextStyle(color: Color(0xFF888888), fontSize: 13)), + ])); +} + +// ── Quick view medicação ligada à criança ────────────────────────── +class _MedQuickView extends StatelessWidget { + final String childId, childName; + const _MedQuickView({required this.childId, required this.childName}); + + @override + Widget build(BuildContext context) { + final sb = Supabase.instance.client; + return Scaffold( + backgroundColor: _bg, + appBar: AppBar(backgroundColor: _card, elevation: 0, + title: Text('Medicação — $childName', style: const TextStyle(color: _amber, fontSize: 15))), + body: StreamBuilder>>( + stream: sb.from('medications').stream(primaryKey: ['id']).eq('child_id', childId), + builder: (ctx, snap) { + if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: _blue)); + if (snap.data!.isEmpty) return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Icon(Icons.medication_outlined, size: 50, color: Color(0xFF333333)), + const SizedBox(height: 10), + const Text('Sem medicação registada', style: TextStyle(color: Color(0xFF888888))), + ])); + return ListView.builder( + padding: const EdgeInsets.all(14), + itemCount: snap.data!.length, + itemBuilder: (_, i) { + final m = snap.data![i]; + final active = m['active'] as bool? ?? false; + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _card, borderRadius: BorderRadius.circular(14), + border: Border.all(color: active ? _amber.withOpacity(0.3) : Colors.white.withOpacity(0.06))), + child: Row(children: [ + Icon(Icons.medication, color: active ? _amber : Colors.white38), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(m['medication_name'] ?? '', style: TextStyle( + color: active ? Colors.white : Colors.white38, fontWeight: FontWeight.bold)), + if ((m['dosage'] ?? '').isNotEmpty) + Text(m['dosage'], style: const TextStyle(color: Color(0xFF888888), fontSize: 12)), + ])), + Switch(value: active, activeColor: _amber, + onChanged: (v) => sb.from('medications').update({'active': v}).eq('id', m['id'])), + ]), + ); + }, + ); + }, + ), + ); + } +} diff --git a/creche_app/lib/features/children/children_list_screen.dart b/creche_app/lib/features/children/children_list_screen.dart new file mode 100644 index 0000000..18b129d --- /dev/null +++ b/creche_app/lib/features/children/children_list_screen.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '/core/supabase_client.dart'; +import '/models/child.dart'; + +class ChildrenListScreen extends ConsumerStatefulWidget { + const ChildrenListScreen({super.key}); + + @override + ConsumerState createState() => _ChildrenListScreenState(); +} + +class _ChildrenListScreenState extends ConsumerState { + String _searchQuery = ''; + String? _selectedClass; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF1A1A2E), + appBar: AppBar( + backgroundColor: const Color(0xFF16213E), + title: const Text('Lista de Crianças', + style: TextStyle(color: Color(0xFF4FC3F7))), + actions: [ + IconButton( + icon: const Icon(Icons.add, color: Color(0xFF4FC3F7)), + onPressed: () => context.go('/child/new'), + ), + ], + ), + body: Column( + children: [ + // Barra de pesquisa + filtro + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: TextField( + onChanged: (v) => + setState(() => _searchQuery = v.toLowerCase()), + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Buscar por nome...', + hintStyle: const TextStyle(color: Color(0xFF888888)), + prefixIcon: const Icon(Icons.search, + color: Color(0xFF4FC3F7)), + filled: true, + fillColor: const Color(0xFF16213E), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: + const BorderSide(color: Color(0xFF333366)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF4FC3F7), width: 2), + ), + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xFF16213E), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF333366)), + ), + child: DropdownButton( + hint: const Text('Turma', + style: TextStyle(color: Color(0xFF888888))), + value: _selectedClass, + dropdownColor: const Color(0xFF16213E), + style: const TextStyle(color: Colors.white), + underline: const SizedBox(), + items: const [ + DropdownMenuItem( + value: null, + child: Text('Todas', + style: TextStyle(color: Color(0xFF888888)))), + DropdownMenuItem( + value: 'bercario1', child: Text('Berçário 1')), + DropdownMenuItem( + value: 'jardim2', child: Text('Jardim 2')), + ], + onChanged: (v) => setState(() => _selectedClass = v), + ), + ), + ], + ), + ), + + // Lista de crianças via stream + Expanded( + child: StreamBuilder>>( + stream: ref + .read(supabaseProvider) + .from('children') + .stream(primaryKey: ['id']), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text('Erro: ${snapshot.error}', + style: const TextStyle(color: Colors.red))); + } + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator( + color: Color(0xFF4FC3F7))); + } + + final children = snapshot.data! + .map(Child.fromMap) + .where((c) => + c.fullName.toLowerCase().contains(_searchQuery) && + (_selectedClass == null || + c.classId == _selectedClass)) + .toList(); + + if (children.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.child_care, + size: 64, color: Color(0xFF333366)), + SizedBox(height: 16), + Text('Nenhuma criança encontrada', + style: TextStyle(color: Color(0xFF888888))), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: children.length, + itemBuilder: (context, index) { + final child = children[index]; + return _ChildListCard( + child: child, + onTap: () => context.go('/child/${child.id}'), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _ChildListCard extends StatelessWidget { + final Child child; + final VoidCallback onTap; + const _ChildListCard({required this.child, required this.onTap}); + + String get _moodEmoji { + switch (child.mood) { + case 'happy': return '😊'; + case 'sad': return '😟'; + case 'sick': return '🤒'; + case 'excited': return '😃'; + default: return '😐'; + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF16213E), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFF333366)), + ), + child: Row( + children: [ + // Avatar + CircleAvatar( + radius: 30, + backgroundImage: child.photoUrl != null + ? NetworkImage(child.photoUrl!) + : null, + backgroundColor: const Color(0xFF4FC3F7).withOpacity(0.2), + child: child.photoUrl == null + ? const Icon(Icons.child_care, + color: Color(0xFF4FC3F7), size: 30) + : null, + ), + const SizedBox(width: 14), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(child.fullName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15)), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.cake, + size: 12, color: Color(0xFF888888)), + const SizedBox(width: 4), + Text('${child.age} anos', + style: const TextStyle( + color: Color(0xFF888888), fontSize: 12)), + const SizedBox(width: 12), + const Icon(Icons.school, + size: 12, color: Color(0xFF888888)), + const SizedBox(width: 4), + Text(child.classId, + style: const TextStyle( + color: Color(0xFF888888), fontSize: 12)), + ], + ), + ], + ), + ), + // Emoji humor + Text(_moodEmoji, style: const TextStyle(fontSize: 26)), + const SizedBox(width: 6), + const Icon(Icons.chevron_right, color: Color(0xFF888888)), + ], + ), + ), + ); + } +} diff --git a/creche_app/lib/features/diary/diary_history_screen.dart b/creche_app/lib/features/diary/diary_history_screen.dart new file mode 100644 index 0000000..6c7e2e1 --- /dev/null +++ b/creche_app/lib/features/diary/diary_history_screen.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:table_calendar/table_calendar.dart'; +import 'package:intl/intl.dart'; +import '/models/daily_diary.dart'; + +class DiaryHistoryScreen extends ConsumerStatefulWidget { + final String childId; + const DiaryHistoryScreen({super.key, required this.childId}); + + @override + ConsumerState createState() => + _DiaryHistoryScreenState(); +} + +class _DiaryHistoryScreenState extends ConsumerState { + DateTime _focusedDay = DateTime.now(); + DateTime? _selectedDay; + + String _moodEmoji(String? mood) { + switch (mood) { + case 'happy': return '😊'; + case 'normal': return '😐'; + case 'sad': return '😟'; + case 'sick': return '🤒'; + case 'excited': return '😃'; + default: return '😐'; + } + } + + @override + Widget build(BuildContext context) { + final supabase = Supabase.instance.client; + + return Scaffold( + backgroundColor: const Color(0xFF1A1A2E), + appBar: AppBar( + backgroundColor: const Color(0xFF16213E), + title: const Text('Histórico do Diário', + style: TextStyle(color: Color(0xFF4FC3F7))), + ), + body: Column( + children: [ + // Calendário + Container( + color: const Color(0xFF16213E), + child: TableCalendar( + firstDay: DateTime.now().subtract(const Duration(days: 365)), + lastDay: DateTime.now(), + focusedDay: _focusedDay, + selectedDayPredicate: (day) => isSameDay(_selectedDay, day), + onDaySelected: (selected, focused) { + setState(() { + _selectedDay = selected; + _focusedDay = focused; + }); + }, + calendarStyle: const CalendarStyle( + defaultTextStyle: TextStyle(color: Colors.white), + weekendTextStyle: TextStyle(color: Color(0xFF4FC3F7)), + selectedDecoration: BoxDecoration( + color: Color(0xFF4FC3F7), + shape: BoxShape.circle, + ), + todayDecoration: BoxDecoration( + color: Color(0xFF333366), + shape: BoxShape.circle, + ), + outsideDaysVisible: false, + ), + headerStyle: const HeaderStyle( + formatButtonVisible: false, + titleCentered: true, + titleTextStyle: TextStyle(color: Colors.white, fontSize: 16), + leftChevronIcon: + Icon(Icons.chevron_left, color: Color(0xFF4FC3F7)), + rightChevronIcon: + Icon(Icons.chevron_right, color: Color(0xFF4FC3F7)), + ), + daysOfWeekStyle: const DaysOfWeekStyle( + weekdayStyle: TextStyle(color: Color(0xFF888888)), + weekendStyle: TextStyle(color: Color(0xFF4FC3F7)), + ), + ), + ), + + // Lista de diários + Expanded( + child: StreamBuilder>>( + stream: supabase + .from('daily_diaries') + .stream(primaryKey: ['id']).eq('child_id', widget.childId), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator( + color: Color(0xFF4FC3F7))); + } + + var diaries = + snapshot.data!.map(DailyDiary.fromMap).toList() + ..sort((a, b) => b.date.compareTo(a.date)); + + if (_selectedDay != null) { + diaries = diaries + .where((d) => isSameDay(d.date, _selectedDay)) + .toList(); + } + + if (diaries.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.book_outlined, + size: 60, color: Color(0xFF333366)), + SizedBox(height: 12), + Text('Sem diários neste dia.', + style: TextStyle(color: Color(0xFF888888))), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: diaries.length, + itemBuilder: (context, i) { + final diary = diaries[i]; + return _DiaryCard(diary: diary, moodEmoji: _moodEmoji); + }, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _DiaryCard extends StatelessWidget { + final DailyDiary diary; + final String Function(String?) moodEmoji; + const _DiaryCard({required this.diary, required this.moodEmoji}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF16213E), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFF333366)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: data + humor + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + DateFormat('d MMMM yyyy', 'pt_PT').format(diary.date), + style: const TextStyle( + color: Color(0xFF4FC3F7), + fontWeight: FontWeight.bold, + fontSize: 15), + ), + Text(moodEmoji(diary.mood), + style: const TextStyle(fontSize: 28)), + ], + ), + const Divider(color: Color(0xFF333366), height: 16), + + // Alimentação + if (diary.food != null && diary.food!.isNotEmpty) + _InfoRow(icon: Icons.restaurant, label: 'Alimentação', value: diary.food!), + + // Sono + if (diary.sleepMinutes != null && diary.sleepMinutes! > 0) + _InfoRow( + icon: Icons.bedtime, + label: 'Sono', + value: + '${diary.sleepMinutes! ~/ 60}h ${diary.sleepMinutes! % 60}min', + ), + + // Atividades + if (diary.activities != null && diary.activities!.isNotEmpty) + _InfoRow( + icon: Icons.sports_esports, + label: 'Atividades', + value: diary.activities!), + + // Notas + if (diary.notes != null && diary.notes!.isNotEmpty) + _InfoRow( + icon: Icons.note, + label: 'Notas', + value: diary.notes!), + + // Fotos + if (diary.photos.isNotEmpty) ...[ + const SizedBox(height: 8), + SizedBox( + height: 80, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: diary.photos.length, + itemBuilder: (context, i) => Padding( + padding: const EdgeInsets.only(right: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network(diary.photos[i], + width: 80, height: 80, fit: BoxFit.cover), + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final IconData icon; + final String label; + final String value; + const _InfoRow( + {required this.icon, required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: const Color(0xFF4FC3F7)), + const SizedBox(width: 8), + Text('$label: ', + style: const TextStyle( + color: Color(0xFF888888), + fontSize: 13, + fontWeight: FontWeight.w500)), + Expanded( + child: Text(value, + style: const TextStyle(color: Colors.white, fontSize: 13))), + ], + ), + ); + } +} diff --git a/creche_app/lib/features/diary/new_diary_screen.dart b/creche_app/lib/features/diary/new_diary_screen.dart new file mode 100644 index 0000000..b58eb39 --- /dev/null +++ b/creche_app/lib/features/diary/new_diary_screen.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/intl.dart'; +import '../../core/auth_provider.dart'; +import '../../models/child.dart'; + +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); +const _green = Color(0xFF2ECC71); +const _amber = Color(0xFFFFB300); +const _red = Color(0xFFE74C3C); + +// Opções de refeição +const _mealOpts = ['bem', 'pouco', 'nao_aceita']; +const _mealLabels = {'bem': '😊 Bem', 'pouco': '😐 Pouco', 'nao_aceita': '😞 Não aceita'}; +const _hygieneOpts = ['normal', 'diarreia', 'rastoso']; +const _hygieneLabels = {'normal': '✅ Normal', 'diarreia': '⚠️ Diarreia', 'rastoso': '😷 Rastoso'}; + +class NewDiaryScreen extends ConsumerStatefulWidget { + final String? childId; + const NewDiaryScreen({super.key, this.childId}); + @override + ConsumerState createState() => _State(); +} + +class _State extends ConsumerState { + final _actCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + final _instNotesCtrl = TextEditingController(); // notas da instituição + String? _childId; + List _children = []; + bool _loading = false; + bool _loadingChildren = true; + + // Sono + bool _sleepMorning = false; + bool _sleepAfternoon = false; + + // Alimentação + String _breakfast = ''; + String _lunch = ''; + String _snackMeal = ''; + + // Higiene + int _hygieneFreq = 0; + String _hygieneState = ''; + + @override + void initState() { + super.initState(); + _childId = widget.childId; + _loadChildren(); + } + + @override + void dispose() { + _actCtrl.dispose(); _notesCtrl.dispose(); _instNotesCtrl.dispose(); + super.dispose(); + } + + Future _loadChildren() async { + try { + final sb = Supabase.instance.client; + final data = await sb.from('children').select().order('full_name'); + setState(() { + _children = data.map((d) => Child.fromMap(d)).toList(); + _loadingChildren = false; + }); + } catch (_) { setState(() => _loadingChildren = false); } + } + + Future _save() async { + if (_childId == null) { _snack('Selecciona uma criança.'); return; } + if (_actCtrl.text.trim().isEmpty) { _snack('Descreve as actividades do dia.'); return; } + setState(() => _loading = true); + + try { + final sb = Supabase.instance.client; + final profile = await ref.read(currentProfileProvider.future); + if (profile == null) throw Exception('Perfil não encontrado'); + final today = DateTime.now().toIso8601String().split('T')[0]; + + // 1. Criar/actualizar diário + final existing = await sb.from('daily_diaries').select('id') + .eq('child_id', _childId!).eq('date', today).maybeSingle(); + + String diaryId; + if (existing != null) { + diaryId = existing['id'] as String; + await sb.from('daily_diaries').update({ + 'activities': _actCtrl.text.trim(), + 'notes': _notesCtrl.text.trim(), + 'institution_notes': _instNotesCtrl.text.trim(), + 'teacher_id': profile.id, + }).eq('id', diaryId); + } else { + final res = await sb.from('daily_diaries').insert({ + 'child_id': _childId, + 'teacher_id': profile.id, + 'date': today, + 'activities': _actCtrl.text.trim(), + 'notes': _notesCtrl.text.trim(), + 'institution_notes': _instNotesCtrl.text.trim(), + }).select('id').single(); + diaryId = res['id'] as String; + } + + // 2. Sono + await sb.from('sleep_records').upsert({ + 'child_id': _childId, + 'diary_id': diaryId, + 'date': today, + 'morning': _sleepMorning, + 'afternoon': _sleepAfternoon, + }, onConflict: 'child_id,date'); + + // 3. Alimentação + if (_breakfast.isNotEmpty || _lunch.isNotEmpty || _snackMeal.isNotEmpty) { + await sb.from('meal_records').upsert({ + 'child_id': _childId, + 'diary_id': diaryId, + 'date': today, + 'breakfast': _breakfast, + 'lunch': _lunch, + 'snack': _snackMeal, + }, onConflict: 'child_id,date'); + } + + // 4. Higiene + if (_hygieneFreq > 0 || _hygieneState.isNotEmpty) { + await sb.from('hygiene_records').upsert({ + 'child_id': _childId, + 'diary_id': diaryId, + 'date': today, + 'frequency': _hygieneFreq, + 'state': _hygieneState, + }, onConflict: 'child_id,date'); + } + + if (mounted) { + _snack('Diário guardado! ✓', ok: true); + await Future.delayed(const Duration(milliseconds: 600)); + if (mounted) context.pop(); + } + } catch (e) { + _snack('Erro: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _snack(String msg, {bool ok = false}) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg, style: const TextStyle(color: Colors.white)), + backgroundColor: ok ? _green : _red, behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)))); + } + + @override + Widget build(BuildContext context) { + final today = DateFormat('d MMMM yyyy', 'pt_PT').format(DateTime.now()); + + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, elevation: 0, + title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('Diário do Dia', style: TextStyle(color: _blue, fontSize: 16, fontWeight: FontWeight.bold)), + Text(today, style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 11)), + ]), + actions: [ + TextButton.icon( + icon: _loading ? const SizedBox(width: 16, height: 16, + child: CircularProgressIndicator(color: _blue, strokeWidth: 2)) + : const Icon(Icons.save_outlined, color: _blue, size: 18), + label: const Text('Guardar', style: TextStyle(color: _blue, fontWeight: FontWeight.bold)), + onPressed: _loading ? null : _save, + ), + ], + ), + body: _loadingChildren + ? const Center(child: CircularProgressIndicator(color: _blue)) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column(children: [ + + // ── Seleccionar criança ───────────────────────── + _Card(title: '👶 Criança', children: [ + DropdownButtonFormField( + value: _childId, + dropdownColor: _card, + style: const TextStyle(color: Colors.white), + decoration: _dec('Selecciona a criança', Icons.child_care), + items: _children.map((c) => DropdownMenuItem( + value: c.id, + child: Text(c.fullName, style: const TextStyle(color: Colors.white)), + )).toList(), + onChanged: (v) => setState(() => _childId = v), + ), + ]), + const SizedBox(height: 12), + + // ── Actividades ───────────────────────────────── + _Card(title: '🎨 Actividades do Dia', children: [ + TextField( + controller: _actCtrl, maxLines: 4, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: _dec('Descreve as actividades, brincadeiras, aprendizagens...', Icons.edit_note), + ), + ]), + const SizedBox(height: 12), + + // ── Controlo de Sono ──────────────────────────── + _Card(title: '😴 Controlo de Sono', children: [ + Row(children: [ + Expanded(child: _CheckTile( + label: 'Manhã', value: _sleepMorning, + onChanged: (v) => setState(() => _sleepMorning = v), + )), + const SizedBox(width: 12), + Expanded(child: _CheckTile( + label: 'Tarde', value: _sleepAfternoon, + onChanged: (v) => setState(() => _sleepAfternoon = v), + )), + ]), + ]), + const SizedBox(height: 12), + + // ── Alimentação ───────────────────────────────── + _Card(title: '🍽️ Alimentação', children: [ + _MealRow(label: 'Pequeno Almoço', value: _breakfast, + onChanged: (v) => setState(() => _breakfast = v)), + const SizedBox(height: 10), + _MealRow(label: 'Almoço', value: _lunch, + onChanged: (v) => setState(() => _lunch = v)), + const SizedBox(height: 10), + _MealRow(label: 'Lanche', value: _snackMeal, + onChanged: (v) => setState(() => _snackMeal = v)), + ]), + const SizedBox(height: 12), + + // ── Higiene/Evacuação ─────────────────────────── + _Card(title: '🚿 Higiene & Evacuação', children: [ + Row(children: [ + const Text('Frequência:', style: TextStyle(color: Color(0xFF888888), fontSize: 13)), + const SizedBox(width: 12), + IconButton( + onPressed: () => setState(() => _hygieneFreq = (_hygieneFreq - 1).clamp(0, 20)), + icon: const Icon(Icons.remove_circle_outline, color: _red), + ), + Text('$_hygieneFreq x', + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + IconButton( + onPressed: () => setState(() => _hygieneFreq++), + icon: const Icon(Icons.add_circle_outline, color: _green), + ), + ]), + const SizedBox(height: 10), + const Text('Estado:', style: TextStyle(color: Color(0xFF888888), fontSize: 13)), + const SizedBox(height: 8), + Wrap(spacing: 8, children: _hygieneOpts.map((opt) { + final sel = _hygieneState == opt; + return GestureDetector( + onTap: () => setState(() => _hygieneState = sel ? '' : opt), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: sel ? _blue.withOpacity(0.2) : Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: sel ? _blue : Colors.white.withOpacity(0.1)), + ), + child: Text(_hygieneLabels[opt]!, + style: TextStyle(color: sel ? _blue : Colors.white70, fontSize: 13)), + ), + ); + }).toList()), + ]), + const SizedBox(height: 12), + + // ── Notas da Educadora ────────────────────────── + _Card(title: '📝 Notas da Educadora', children: [ + TextField( + controller: _notesCtrl, maxLines: 3, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: _dec('Observações, comportamento, necessidades especiais...', Icons.notes), + ), + ]), + const SizedBox(height: 12), + + // ── Notas da Instituição ──────────────────────── + _Card(title: '🏫 Notas da Instituição', children: [ + TextField( + controller: _instNotesCtrl, maxLines: 3, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: _dec('Comunicado para o encarregado de educação...', Icons.business_outlined), + ), + ]), + const SizedBox(height: 80), + ]), + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: _blue, + onPressed: _loading ? null : _save, + icon: _loading ? const SizedBox(width: 18, height: 18, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Icon(Icons.save, color: Colors.white), + label: const Text('Guardar Diário', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ); + } + + InputDecoration _dec(String hint, IconData icon) => InputDecoration( + hintText: hint, hintStyle: const TextStyle(color: Color(0xFF555555), fontSize: 13), + prefixIcon: Icon(icon, color: _blue.withOpacity(0.6), size: 18), + filled: true, fillColor: Colors.white.withOpacity(0.04), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _blue, width: 1.5)), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ); +} + +class _Card extends StatelessWidget { + final String title; final List children; + const _Card({required this.title, required this.children}); + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.07))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + ...children, + ]), + ); +} + +class _CheckTile extends StatelessWidget { + final String label; final bool value; final ValueChanged onChanged; + const _CheckTile({required this.label, required this.value, required this.onChanged}); + @override + Widget build(BuildContext context) => GestureDetector( + onTap: () => onChanged(!value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: value ? _blue.withOpacity(0.12) : Colors.white.withOpacity(0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: value ? _blue.withOpacity(0.4) : Colors.white.withOpacity(0.09)), + ), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(value ? Icons.check_circle : Icons.circle_outlined, + color: value ? _blue : Colors.white38, size: 18), + const SizedBox(width: 8), + Text(label, style: TextStyle(color: value ? _blue : Colors.white60, fontWeight: FontWeight.w500)), + ]), + ), + ); +} + +class _MealRow extends StatelessWidget { + final String label, value; final ValueChanged onChanged; + const _MealRow({required this.label, required this.value, required this.onChanged}); + @override + Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(color: Color(0xFF888888), fontSize: 12)), + const SizedBox(height: 6), + Row(children: _mealOpts.map((opt) { + final sel = value == opt; + Color c = opt == 'bem' ? const Color(0xFF2ECC71) : opt == 'pouco' ? const Color(0xFFFFB300) : const Color(0xFFE74C3C); + return Expanded(child: GestureDetector( + onTap: () => onChanged(sel ? '' : opt), + child: Container( + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: sel ? c.withOpacity(0.15) : Colors.white.withOpacity(0.04), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: sel ? c.withOpacity(0.5) : Colors.white.withOpacity(0.08)), + ), + child: Text(_mealLabels[opt]!, textAlign: TextAlign.center, + style: TextStyle(color: sel ? c : Colors.white54, fontSize: 11, fontWeight: FontWeight.w500)), + ), + )); + }).toList()), + ]); +} diff --git a/creche_app/lib/features/home/home_dashboard.dart b/creche_app/lib/features/home/home_dashboard.dart new file mode 100644 index 0000000..a29e8d6 --- /dev/null +++ b/creche_app/lib/features/home/home_dashboard.dart @@ -0,0 +1,741 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/intl.dart'; +import '/core/auth_provider.dart'; +import '/models/profile.dart'; +import '/models/child.dart'; +import '/models/daily_access_approval.dart'; + +class HomeDashboard extends ConsumerWidget { + const HomeDashboard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profileAsync = ref.watch(currentProfileProvider); + + return profileAsync.when( + data: (profile) { + if (profile == null) { + return const Scaffold( + body: Center(child: Text('Perfil não encontrado')), + ); + } + switch (profile.role) { + case 'principal': + case 'admin': + return _AdminDashboard(profile: profile); + case 'teacher': + return _TeacherDashboard(profile: profile); + case 'parent': + return _ParentDashboard(profile: profile); + default: + return const Scaffold( + body: Center(child: Text('Role desconhecido'))); + } + }, + loading: () => + const Scaffold(body: Center(child: CircularProgressIndicator())), + error: (e, _) => Scaffold(body: Center(child: Text('Erro: $e'))), + ); + } +} + +// ─────────────── ADMIN DASHBOARD ─────────────── +class _AdminDashboard extends ConsumerWidget { + final Profile profile; + const _AdminDashboard({required this.profile}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final supabase = Supabase.instance.client; + + return Scaffold( + backgroundColor: const Color(0xFF0D1117), + appBar: AppBar( + backgroundColor: const Color(0xFF161B22), + title: Row( + children: [ + Image.asset('assets/logo.png', height: 36, + errorBuilder: (_, __, ___) => + const Icon(Icons.child_care, color: Color(0xFF4FC3F7))), + const SizedBox(width: 10), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sementes do Futuro', + style: TextStyle( + color: Color(0xFF4FC3F7), + fontSize: 14, + fontWeight: FontWeight.bold)), + Text('Dashboard Admin', + style: + TextStyle(color: Color(0xFF888888), fontSize: 11)), + ], + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.notifications_outlined, + color: Color(0xFF4FC3F7)), + onPressed: () => context.go('/announcements'), + ), + IconButton( + icon: const Icon(Icons.settings_outlined, + color: Color(0xFF4FC3F7)), + onPressed: () => context.go('/settings'), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Olá, ${profile.fullName.split(' ').first}! 👋', + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text(DateFormat('EEEE, d MMMM yyyy', 'pt_PT').format(DateTime.now()), + style: const TextStyle(color: Color(0xFF888888), fontSize: 13)), + const SizedBox(height: 24), + + // Quick actions + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _QuickAction( + icon: Icons.add_circle, + label: 'Diário', + onTap: () => context.go('/new-diary')), + _QuickAction( + icon: Icons.check_circle, + label: 'Presença', + onTap: () => context.go('/attendance')), + _QuickAction( + icon: Icons.attach_money, + label: 'Pagamentos', + onTap: () => context.go('/payments')), + _QuickAction( + icon: Icons.campaign, + label: 'Avisos', + onTap: () => context.go('/announcements')), + ], + ), + const SizedBox(height: 24), + + // Cards de estatísticas + const Text('Visão Geral', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.5, + children: const [ + _StatCard(title: 'Crianças Hoje', value: '–', icon: Icons.child_care, color: Color(0xFF4FC3F7)), + _StatCard(title: 'Presença', value: '–%', icon: Icons.check_circle, color: Color(0xFFA5D6A7)), + _StatCard(title: 'Pendentes', value: '–', icon: Icons.payment, color: Color(0xFFFFCC02)), + _StatCard(title: 'Avisos', value: '–', icon: Icons.campaign, color: Color(0xFFFF7043)), + ], + ), + const SizedBox(height: 24), + + // Aprovações pendentes + const Text('Pedidos de Acesso Hoje', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + StreamBuilder>>( + stream: supabase + .from('daily_access_approvals') + .stream(primaryKey: ['id']).eq('status', 'pending'), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator( + color: Color(0xFF4FC3F7))); + } + final approvals = snapshot.data!; + if (approvals.isEmpty) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + children: [ + Icon(Icons.check_circle, color: Color(0xFFA5D6A7)), + SizedBox(width: 8), + Text('Nenhum pedido pendente', + style: TextStyle(color: Color(0xFF888888))), + ], + ), + ); + } + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: approvals.length, + itemBuilder: (context, index) { + final approval = + DailyAccessApproval.fromMap(approvals[index]); + return _ApprovalCard( + approval: approval, + onApprove: () => _approve(supabase, approval.id), + onReject: () => _reject(supabase, approval.id), + ); + }, + ); + }, + ), + const SizedBox(height: 80), + ], + ), + ), + bottomNavigationBar: _AdminBottomNav(), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: const Color(0xFF4FC3F7), + icon: const Icon(Icons.person_add, color: Colors.white), + label: + const Text('Nova Criança', style: TextStyle(color: Colors.white)), + onPressed: () => context.go('/child/new'), + ), + ); + } + + Future _approve(SupabaseClient supabase, String id) async { + await supabase.from('daily_access_approvals').update({ + 'status': 'approved', + 'approved_at': DateTime.now().toIso8601String(), + 'approved_by': supabase.auth.currentUser!.id, + }).eq('id', id); + } + + Future _reject(SupabaseClient supabase, String id) async { + await supabase + .from('daily_access_approvals') + .update({'status': 'rejected'}).eq('id', id); + } +} + +// ─────────────── TEACHER DASHBOARD ─────────────── +class _TeacherDashboard extends ConsumerWidget { + final Profile profile; + const _TeacherDashboard({required this.profile}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final supabase = Supabase.instance.client; + + return Scaffold( + backgroundColor: const Color(0xFF0D1117), + appBar: AppBar( + backgroundColor: const Color(0xFF161B22), + title: const Text('Minha Turma', + style: TextStyle(color: Color(0xFF4FC3F7))), + actions: [ + IconButton( + icon: const Icon(Icons.chat_outlined, color: Color(0xFF4FC3F7)), + onPressed: () => context.go('/chat'), + ), + IconButton( + icon: + const Icon(Icons.person_outline, color: Color(0xFF4FC3F7)), + onPressed: () => context.go('/profile'), + ), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Olá, ${profile.fullName.split(' ').first}! 👋', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Crianças da tua turma hoje:', + style: TextStyle(color: Color(0xFF888888), fontSize: 14)), + ), + const SizedBox(height: 12), + Expanded( + child: StreamBuilder>>( + stream: supabase + .from('children') + .stream(primaryKey: ['id']).eq('teacher_id', profile.id), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: + CircularProgressIndicator(color: Color(0xFF4FC3F7))); + } + final children = + snapshot.data!.map(Child.fromMap).toList(); + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: children.length, + itemBuilder: (context, index) { + final child = children[index]; + return _ChildCard( + child: child, + onTap: () => context.go('/child/${child.id}'), + ); + }, + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: const Color(0xFF4FC3F7), + icon: const Icon(Icons.add, color: Colors.white), + label: + const Text('Novo Diário', style: TextStyle(color: Colors.white)), + onPressed: () => context.go('/new-diary'), + ), + bottomNavigationBar: _TeacherBottomNav(), + ); + } +} + +// ─────────────── PARENT DASHBOARD ─────────────── +class _ParentDashboard extends ConsumerWidget { + final Profile profile; + const _ParentDashboard({required this.profile}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + backgroundColor: const Color(0xFF0D1117), + appBar: AppBar( + backgroundColor: const Color(0xFF161B22), + title: Image.asset('assets/logo.png', height: 36, + errorBuilder: (_, __, ___) => + const Icon(Icons.child_care, color: Color(0xFF4FC3F7))), + centerTitle: true, + actions: [ + IconButton( + icon: + const Icon(Icons.notifications_outlined, color: Color(0xFF4FC3F7)), + onPressed: () => context.go('/announcements'), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + CircleAvatar( + radius: 30, + backgroundColor: const Color(0xFF4FC3F7), + child: Text( + profile.fullName.isNotEmpty + ? profile.fullName[0].toUpperCase() + : 'P', + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Bem-vindo(a)!', + style: TextStyle( + color: Color(0xFF888888), fontSize: 12)), + Text(profile.fullName, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold)), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + const Text('Ações Rápidas', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _ActionButton( + icon: Icons.book_outlined, + label: 'Ver Diário', + onTap: () => context.go('/children'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ActionButton( + icon: Icons.restaurant_menu, + label: 'Cardápio', + onTap: () => context.go('/menu'), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _ActionButton( + icon: Icons.medication, + label: 'Medicação', + onTap: () => context.go('/medication'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ActionButton( + icon: Icons.chat_outlined, + label: 'Falar c/ Educadora', + onTap: () => context.go('/chat'), + ), + ), + ], + ), + ], + ), + ), + bottomNavigationBar: _ParentBottomNav(), + ); + } +} + +// ─────────────── WIDGETS AUXILIARES ─────────────── + +class _QuickAction extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + const _QuickAction( + {required this.icon, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF333366)), + ), + child: Icon(icon, color: const Color(0xFF4FC3F7), size: 26), + ), + const SizedBox(height: 6), + Text(label, + style: const TextStyle(color: Color(0xFF888888), fontSize: 12)), + ], + ), + ); + } +} + +class _StatCard extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final Color color; + const _StatCard( + {required this.title, + required this.value, + required this.icon, + required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 22), + const SizedBox(height: 4), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text(value, + style: TextStyle( + color: color, + fontSize: 20, + fontWeight: FontWeight.bold)), + ), + Text(title, + style: const TextStyle( + color: Color(0xFF888888), fontSize: 10), + overflow: TextOverflow.ellipsis), + ], + ), + ); + } +} + +class _ApprovalCard extends StatelessWidget { + final DailyAccessApproval approval; + final VoidCallback onApprove; + final VoidCallback onReject; + const _ApprovalCard( + {required this.approval, + required this.onApprove, + required this.onReject}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFFCC02).withOpacity(0.3)), + ), + child: Row( + children: [ + const Icon(Icons.person_outline, color: Color(0xFF4FC3F7)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Funcionário: ${approval.userId.substring(0, 8)}...', + style: const TextStyle(color: Colors.white, fontSize: 13)), + Text( + 'IP: ${approval.ipAddress ?? 'N/A'} • ${DateFormat('HH:mm').format(approval.approvalDate)}', + style: const TextStyle( + color: Color(0xFF888888), fontSize: 11)), + ], + ), + ), + IconButton( + icon: const Icon(Icons.check_circle, color: Color(0xFFA5D6A7)), + onPressed: onApprove, + tooltip: 'Aprovar', + ), + IconButton( + icon: const Icon(Icons.cancel, color: Colors.red), + onPressed: onReject, + tooltip: 'Rejeitar', + ), + ], + ), + ); + } +} + +class _ChildCard extends StatelessWidget { + final Child child; + final VoidCallback onTap; + const _ChildCard({required this.child, required this.onTap}); + + String get _moodEmoji { + switch (child.mood) { + case 'happy': return '😊'; + case 'sad': return '😟'; + case 'sick': return '🤒'; + case 'excited': return '😃'; + default: return '😐'; + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF333366)), + ), + child: Row( + children: [ + CircleAvatar( + radius: 26, + backgroundImage: child.photoUrl != null + ? NetworkImage(child.photoUrl!) + : null, + backgroundColor: const Color(0xFF4FC3F7).withOpacity(0.2), + child: child.photoUrl == null + ? const Icon(Icons.child_care, + color: Color(0xFF4FC3F7), size: 28) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(child.fullName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15)), + Text('${child.age} anos', + style: const TextStyle( + color: Color(0xFF888888), fontSize: 12)), + ], + ), + ), + Text(_moodEmoji, style: const TextStyle(fontSize: 28)), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Color(0xFF888888)), + ], + ), + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + const _ActionButton( + {required this.icon, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF333366)), + ), + child: Column( + children: [ + Icon(icon, color: const Color(0xFF4FC3F7), size: 28), + const SizedBox(height: 8), + Text(label, + style: + const TextStyle(color: Colors.white, fontSize: 13), + textAlign: TextAlign.center), + ], + ), + ), + ); + } +} + +// Bottom Navs +class _AdminBottomNav extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BottomNavigationBar( + backgroundColor: const Color(0xFF161B22), + selectedItemColor: const Color(0xFF4FC3F7), + unselectedItemColor: const Color(0xFF888888), + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Início'), + BottomNavigationBarItem(icon: Icon(Icons.child_care), label: 'Crianças'), + BottomNavigationBarItem(icon: Icon(Icons.check_box), label: 'Presença'), + BottomNavigationBarItem(icon: Icon(Icons.people), label: 'Utilizadores'), + BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Perfil'), + ], + onTap: (i) { + final routes = ['/home', '/children', '/attendance', '/users', '/profile']; + context.go(routes[i]); + }, + ); + } +} + +class _TeacherBottomNav extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BottomNavigationBar( + backgroundColor: const Color(0xFF161B22), + selectedItemColor: const Color(0xFF4FC3F7), + unselectedItemColor: const Color(0xFF888888), + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Início'), + BottomNavigationBarItem(icon: Icon(Icons.child_care), label: 'Crianças'), + BottomNavigationBarItem(icon: Icon(Icons.book), label: 'Diários'), + BottomNavigationBarItem(icon: Icon(Icons.chat), label: 'Chat'), + BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Perfil'), + ], + onTap: (i) { + final routes = ['/home', '/children', '/new-diary', '/chat', '/profile']; + context.go(routes[i]); + }, + ); + } +} + +class _ParentBottomNav extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BottomNavigationBar( + backgroundColor: const Color(0xFF161B22), + selectedItemColor: const Color(0xFF4FC3F7), + unselectedItemColor: const Color(0xFF888888), + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Início'), + BottomNavigationBarItem(icon: Icon(Icons.child_care), label: 'Filhos'), + BottomNavigationBarItem(icon: Icon(Icons.restaurant_menu), label: 'Cardápio'), + BottomNavigationBarItem(icon: Icon(Icons.chat), label: 'Chat'), + BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Perfil'), + ], + onTap: (i) { + final routes = ['/home', '/children', '/menu', '/chat', '/profile']; + context.go(routes[i]); + }, + ); + } +} diff --git a/creche_app/lib/features/medication/medication_screen.dart b/creche_app/lib/features/medication/medication_screen.dart new file mode 100644 index 0000000..7deb777 --- /dev/null +++ b/creche_app/lib/features/medication/medication_screen.dart @@ -0,0 +1,364 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; +import '/core/auth_provider.dart'; +import '/models/child.dart'; + +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); +const _red = Color(0xFFE74C3C); +const _green = Color(0xFF2ECC71); +const _amber = Color(0xFFFFB300); + +class MedicationScreen extends ConsumerStatefulWidget { + const MedicationScreen({super.key}); + @override + ConsumerState createState() => _State(); +} + +class _State extends ConsumerState with SingleTickerProviderStateMixin { + late TabController _tabs; + + @override + void initState() { super.initState(); _tabs = TabController(length: 2, vsync: this); } + @override + void dispose() { _tabs.dispose(); super.dispose(); } + + @override + Widget build(BuildContext context) { + final profile = ref.watch(currentProfileProvider).valueOrNull; + final isParent = profile?.role == 'parent'; + + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, elevation: 0, + title: const Text('Medicação', style: TextStyle(color: _blue, fontWeight: FontWeight.bold)), + bottom: TabBar( + controller: _tabs, + indicatorColor: _blue, labelColor: _blue, + unselectedLabelColor: Colors.white38, + tabs: [ + const Tab(text: 'Activa'), + Tab(text: isParent ? 'Registar' : 'Histórico', icon: null), + ], + ), + ), + body: TabBarView(controller: _tabs, children: [ + _ActiveMeds(isParent: isParent ?? false), + isParent ? const _AddMedication() : const _MedHistory(), + ]), + floatingActionButton: isParent == true ? null : FloatingActionButton.extended( + backgroundColor: _amber, + icon: const Icon(Icons.medication, color: Colors.white), + label: const Text('Registar Toma', style: TextStyle(color: Colors.white)), + onPressed: () => _showAdministerDialog(context), + ), + ); + } + + void _showAdministerDialog(BuildContext ctx) { + showModalBottomSheet(context: ctx, isScrollControlled: true, + backgroundColor: _card, shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (_) => const _AdministerForm()); + } +} + +// ── Lista medicação activa ───────────────────────────────────────── +class _ActiveMeds extends ConsumerWidget { + final bool isParent; + const _ActiveMeds({required this.isParent}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sb = Supabase.instance.client; + final profile = ref.watch(currentProfileProvider).valueOrNull; + + return StreamBuilder>>( + stream: sb.from('medications').stream(primaryKey: ['id']) + .eq('active', true).order('child_id'), + builder: (context, snapshot) { + if (snapshot.hasError) return _err('Erro: ${snapshot.error}'); + if (!snapshot.hasData) return const Center(child: CircularProgressIndicator(color: _blue)); + final meds = snapshot.data!; + if (meds.isEmpty) return _empty('Nenhuma medicação activa'); + + // Filtrar por encarregado se parent + return FutureBuilder>( + future: isParent ? _myChildIds(sb, profile?.id) : Future.value(null), + builder: (ctx, childIds) { + final filtered = childIds.data != null + ? meds.where((m) => childIds.data!.contains(m['child_id'])).toList() + : meds; + if (filtered.isEmpty) return _empty('Sem medicação activa para os teus filhos'); + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: filtered.length, + itemBuilder: (_, i) => _MedCard(med: filtered[i], isParent: isParent), + ); + }, + ); + }, + ); + } + + Future> _myChildIds(SupabaseClient sb, String? guardianId) async { + if (guardianId == null) return []; + final rows = await sb.from('child_guardians').select('child_id').eq('guardian_id', guardianId); + return rows.map((r) => r['child_id'] as String).toList(); + } +} + +class _MedCard extends StatelessWidget { + final Map med; + final bool isParent; + const _MedCard({required this.med, required this.isParent}); + + @override + Widget build(BuildContext context) { + final name = med['medication_name'] ?? ''; + final dose = med['dosage'] ?? ''; + final times = (med['schedule'] as List?)?.join(', ') ?? ''; + final notes = med['notes'] ?? ''; + final childName = med['child_name'] ?? 'Criança'; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _card, borderRadius: BorderRadius.circular(16), + border: Border.all(color: _amber.withOpacity(0.3)), + boxShadow: [BoxShadow(color: _amber.withOpacity(0.05), blurRadius: 12)], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: _amber.withOpacity(0.12), shape: BoxShape.circle), + child: const Icon(Icons.medication, color: _amber, size: 20), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(name, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)), + Text(childName, style: const TextStyle(color: Color(0xFF888888), fontSize: 12)), + ])), + if (!isParent) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration(color: _green.withOpacity(0.12), borderRadius: BorderRadius.circular(20)), + child: const Text('Activa', style: TextStyle(color: _green, fontSize: 11)), + ), + ]), + const SizedBox(height: 12), + if (dose.isNotEmpty) _InfoRow(icon: Icons.scale, text: 'Dosagem: $dose'), + if (times.isNotEmpty) _InfoRow(icon: Icons.schedule, text: 'Horários: $times'), + if (notes.isNotEmpty) _InfoRow(icon: Icons.notes, text: notes), + ]), + ); + } +} + +class _InfoRow extends StatelessWidget { + final IconData icon; final String text; + const _InfoRow({required this.icon, required this.text}); + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row(children: [ + Icon(icon, size: 14, color: const Color(0xFF888888)), + const SizedBox(width: 6), + Expanded(child: Text(text, style: const TextStyle(color: Color(0xFF888888), fontSize: 12))), + ]), + ); +} + +// ── Encarregado regista medicação ────────────────────────────────── +class _AddMedication extends ConsumerStatefulWidget { + const _AddMedication(); + @override + ConsumerState<_AddMedication> createState() => _AddMedState(); +} + +class _AddMedState extends ConsumerState<_AddMedication> { + final _nameCtrl = TextEditingController(); + final _doseCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + String? _childId; + final List _schedules = []; + final _timeCtrl = TextEditingController(); + bool _loading = false; + List _children = []; + + @override + void initState() { super.initState(); _loadChildren(); } + @override + void dispose() { _nameCtrl.dispose(); _doseCtrl.dispose(); _notesCtrl.dispose(); _timeCtrl.dispose(); super.dispose(); } + + Future _loadChildren() async { + final sb = Supabase.instance.client; + final profile = await ref.read(currentProfileProvider.future); + if (profile == null) return; + final rows = await sb.from('child_guardians').select('children(*)').eq('guardian_id', profile.id); + if (mounted) setState(() { + _children = rows.map((r) => Child.fromMap(r['children'] as Map)).toList(); + }); + } + + Future _save() async { + if (_childId == null || _nameCtrl.text.trim().isEmpty) { + _snack('Preenche o medicamento e a criança.'); return; + } + setState(() => _loading = true); + try { + final sb = Supabase.instance.client; + final profile = await ref.read(currentProfileProvider.future); + await sb.from('medications').insert({ + 'id': const Uuid().v4(), + 'child_id': _childId, + 'medication_name': _nameCtrl.text.trim(), + 'dosage': _doseCtrl.text.trim(), + 'schedule': _schedules, + 'notes': _notesCtrl.text.trim(), + 'reported_by': profile?.id, + 'active': true, + }); + _nameCtrl.clear(); _doseCtrl.clear(); _notesCtrl.clear(); + setState(() { _schedules.clear(); _childId = null; }); + _snack('Medicação registada! A equipa foi notificada.', ok: true); + } catch (e) { _snack('Erro: $e'); } + finally { if (mounted) setState(() => _loading = false); } + } + + void _snack(String msg, {bool ok = false}) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg, style: const TextStyle(color: Colors.white)), + backgroundColor: ok ? _green : _red, behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)))); + + @override + Widget build(BuildContext context) => SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _sec('👶 Criança'), + DropdownButtonFormField( + value: _childId, dropdownColor: _card, + style: const TextStyle(color: Colors.white), + decoration: _dec('Selecciona o teu filho', Icons.child_care), + items: _children.map((c) => DropdownMenuItem(value: c.id, + child: Text(c.fullName, style: const TextStyle(color: Colors.white)))).toList(), + onChanged: (v) => setState(() => _childId = v), + ), + const SizedBox(height: 16), + _sec('💊 Medicamento'), + TextField(controller: _nameCtrl, style: const TextStyle(color: Colors.white), + decoration: _dec('Nome do medicamento (ex: Paracetamol)', Icons.medication)), + const SizedBox(height: 12), + TextField(controller: _doseCtrl, style: const TextStyle(color: Colors.white), + decoration: _dec('Dosagem (ex: 5ml, 1 comprimido)', Icons.scale)), + const SizedBox(height: 16), + _sec('⏰ Horários de Toma'), + Row(children: [ + Expanded(child: TextField( + controller: _timeCtrl, style: const TextStyle(color: Colors.white), + decoration: _dec('Ex: 08:00, depois do almoço', Icons.schedule), + )), + const SizedBox(width: 8), + GestureDetector( + onTap: () { if (_timeCtrl.text.trim().isNotEmpty) { + setState(() { _schedules.add(_timeCtrl.text.trim()); _timeCtrl.clear(); }); + }}, + child: Container( + padding: const EdgeInsets.all(14), + decoration: const BoxDecoration(color: _blue, shape: BoxShape.circle), + child: const Icon(Icons.add, color: Colors.white, size: 20), + ), + ), + ]), + if (_schedules.isNotEmpty) Wrap(spacing: 6, children: _schedules.asMap().entries.map((e) => + Chip(label: Text(e.value, style: const TextStyle(color: Colors.white, fontSize: 12)), + backgroundColor: _blue.withOpacity(0.2), + deleteIconColor: Colors.white54, + onDeleted: () => setState(() => _schedules.removeAt(e.key)))).toList()), + const SizedBox(height: 12), + _sec('📝 Observações (opcional)'), + TextField(controller: _notesCtrl, maxLines: 3, style: const TextStyle(color: Colors.white), + decoration: _dec('Instruções especiais, alergias, avisos...', Icons.notes)), + const SizedBox(height: 24), + GestureDetector( + onTap: _loading ? null : _save, + child: Container( + height: 52, width: double.infinity, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [_amber, Color(0xFFFF8F00)]), + borderRadius: BorderRadius.circular(14), + boxShadow: [BoxShadow(color: _amber.withOpacity(0.3), blurRadius: 16, offset: const Offset(0,6))], + ), + child: Center(child: _loading + ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.send_outlined, color: Colors.white, size: 18), + SizedBox(width: 10), + Text('Enviar à Creche', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)), + ])), + ), + ), + const SizedBox(height: 32), + ]), + ); + + Widget _sec(String t) => Padding(padding: const EdgeInsets.only(bottom: 8), + child: Text(t, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold))); + + InputDecoration _dec(String hint, IconData icon) => InputDecoration( + hintText: hint, hintStyle: const TextStyle(color: Color(0xFF555555), fontSize: 13), + prefixIcon: Icon(icon, color: _blue.withOpacity(0.6), size: 18), + filled: true, fillColor: Colors.white.withOpacity(0.04), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _blue, width: 1.5)), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ); +} + +// ── Histórico (staff) ────────────────────────────────────────────── +class _MedHistory extends StatelessWidget { + const _MedHistory(); + @override + Widget build(BuildContext context) { + final sb = Supabase.instance.client; + return StreamBuilder>>( + stream: sb.from('medications').stream(primaryKey: ['id']).order('created_at', ascending: false), + builder: (ctx, snap) { + if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: _blue)); + if (snap.data!.isEmpty) return _empty('Sem registos de medicação'); + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: snap.data!.length, + itemBuilder: (_, i) => _MedCard(med: snap.data![i], isParent: false), + ); + }, + ); + } +} + +class _AdministerForm extends StatelessWidget { + const _AdministerForm(); + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.all(20), + child: Text('Formulário de toma (em breve)', style: TextStyle(color: Colors.white)), + ); +} + +Widget _empty(String msg) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.medication_outlined, size: 60, color: Colors.white.withOpacity(0.1)), + const SizedBox(height: 12), + Text(msg, style: const TextStyle(color: Color(0xFF888888), fontSize: 13)), +])); + +Widget _err(String msg) => Center(child: Text(msg, style: const TextStyle(color: Colors.red))); diff --git a/creche_app/lib/features/menu/menu_screen.dart b/creche_app/lib/features/menu/menu_screen.dart new file mode 100644 index 0000000..e55e24a --- /dev/null +++ b/creche_app/lib/features/menu/menu_screen.dart @@ -0,0 +1,451 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/intl.dart'; +import '/core/auth_provider.dart'; + +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); +const _green = Color(0xFF2ECC71); +const _amber = Color(0xFFFFB300); +const _red = Color(0xFFE74C3C); + +const _mealNames = ['Pequeno Almoço', 'Almoço', 'Lanche da Tarde', 'Jantar']; +const _mealIcons = [Icons.free_breakfast, Icons.lunch_dining, Icons.icecream, Icons.dinner_dining]; +const _weekDays = ['Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta']; + +class MenuScreen extends ConsumerStatefulWidget { + const MenuScreen({super.key}); + @override + ConsumerState createState() => _State(); +} + +class _State extends ConsumerState with SingleTickerProviderStateMixin { + late TabController _tabs; + DateTime _selectedWeek = _startOfWeek(DateTime.now()); + bool _isAdmin = false; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 2, vsync: this); + _checkRole(); + } + + @override + void dispose() { _tabs.dispose(); super.dispose(); } + + Future _checkRole() async { + final p = await ref.read(currentProfileProvider.future); + if (mounted) setState(() => _isAdmin = p?.role == 'principal' || p?.role == 'admin'); + } + + static DateTime _startOfWeek(DateTime d) { + final diff = d.weekday - 1; + return DateTime(d.year, d.month, d.day - diff); + } + + String get _weekLabel { + final end = _selectedWeek.add(const Duration(days: 4)); + final fmt = DateFormat('d MMM', 'pt_PT'); + return '${fmt.format(_selectedWeek)} – ${fmt.format(end)}'; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, elevation: 0, + title: const Text('Cardápio', style: TextStyle(color: _blue, fontWeight: FontWeight.bold)), + bottom: TabBar( + controller: _tabs, indicatorColor: _blue, labelColor: _blue, + unselectedLabelColor: Colors.white38, + tabs: const [Tab(text: '📅 Semanal'), Tab(text: '📋 Mensal')], + ), + actions: [ + if (_isAdmin) + IconButton( + icon: const Icon(Icons.add_circle_outline, color: _amber), + tooltip: 'Publicar cardápio', + onPressed: () => _showPublishDialog(context), + ), + ], + ), + body: TabBarView(controller: _tabs, children: [ + _WeeklyMenu(week: _selectedWeek, weekLabel: _weekLabel, + onPrev: () => setState(() => _selectedWeek = _selectedWeek.subtract(const Duration(days: 7))), + onNext: () => setState(() => _selectedWeek = _selectedWeek.add(const Duration(days: 7)))), + const _MonthlyMenu(), + ]), + ); + } + + void _showPublishDialog(BuildContext ctx) { + showModalBottomSheet( + context: ctx, isScrollControlled: true, + backgroundColor: _card, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (_) => _PublishMenuForm(week: _selectedWeek), + ); + } +} + +// ── Cardápio Semanal ────────────────────────────────────────────── +class _WeeklyMenu extends StatelessWidget { + final DateTime week; + final String weekLabel; + final VoidCallback onPrev, onNext; + const _WeeklyMenu({required this.week, required this.weekLabel, required this.onPrev, required this.onNext}); + + @override + Widget build(BuildContext context) { + final sb = Supabase.instance.client; + final weekStr = DateFormat('yyyy-MM-dd').format(week); + + return Column(children: [ + // Navegação de semana + Container( + color: _card, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + IconButton(onPressed: onPrev, icon: const Icon(Icons.chevron_left, color: _blue)), + Expanded(child: Text(weekLabel, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14))), + IconButton(onPressed: onNext, icon: const Icon(Icons.chevron_right, color: _blue)), + ]), + ), + Expanded( + child: FutureBuilder>>( + future: sb.from('menu_items').select() + .eq('week_start', weekStr) + .order('day_index').order('meal_index'), + builder: (ctx, snap) { + if (snap.hasError) return _err('Erro: ${snap.error}'); + if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: _blue)); + + final items = snap.data!; + if (items.isEmpty) return _emptyMenu(); + + // Agrupar por dia + final Map>> byDay = {}; + for (final item in items) { + final day = (item['day_index'] as int?) ?? 0; + byDay.putIfAbsent(day, () => []).add(item); + } + + return ListView.builder( + padding: const EdgeInsets.all(14), + itemCount: 5, + itemBuilder: (_, i) { + final date = week.add(Duration(days: i)); + final dayMeals = byDay[i] ?? []; + return _DayCard( + dayName: _weekDays[i], + date: DateFormat('d/MM').format(date), + meals: dayMeals, + isToday: DateFormat('yyyy-MM-dd').format(DateTime.now()) == + DateFormat('yyyy-MM-dd').format(date), + ); + }, + ); + }, + ), + ), + ]); + } +} + +class _DayCard extends StatelessWidget { + final String dayName, date; + final List> meals; + final bool isToday; + const _DayCard({required this.dayName, required this.date, required this.meals, required this.isToday}); + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: _card, borderRadius: BorderRadius.circular(16), + border: Border.all(color: isToday ? _blue.withOpacity(0.5) : Colors.white.withOpacity(0.07)), + boxShadow: isToday ? [BoxShadow(color: _blue.withOpacity(0.08), blurRadius: 12)] : null, + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header do dia + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isToday ? _blue.withOpacity(0.12) : Colors.white.withOpacity(0.03), + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Row(children: [ + Text(dayName, style: TextStyle( + color: isToday ? _blue : Colors.white, + fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(width: 8), + Text(date, style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 12)), + if (isToday) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: _blue.withOpacity(0.2), borderRadius: BorderRadius.circular(10)), + child: const Text('Hoje', style: TextStyle(color: _blue, fontSize: 10, fontWeight: FontWeight.bold)), + ), + ], + ]), + ), + if (meals.isEmpty) + Padding( + padding: const EdgeInsets.all(14), + child: Text('Sem ementa publicada', style: TextStyle(color: Colors.white.withOpacity(0.25), fontSize: 12)), + ) + else + ...meals.map((m) { + final mealIdx = (m['meal_index'] as int?) ?? 0; + final name = mealIdx < _mealIcons.length ? _mealNames[mealIdx] : 'Refeição'; + final icon = mealIdx < _mealIcons.length ? _mealIcons[mealIdx] : Icons.restaurant; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + child: Row(children: [ + Icon(icon, size: 16, color: _amber), + const SizedBox(width: 10), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(name, style: const TextStyle(color: Color(0xFF888888), fontSize: 10)), + Text(m['description'] ?? '', style: const TextStyle(color: Colors.white, fontSize: 13)), + ]), + ]), + ); + }), + const SizedBox(height: 4), + ]), + ); +} + +// ── Cardápio Mensal ─────────────────────────────────────────────── +class _MonthlyMenu extends StatefulWidget { + const _MonthlyMenu(); + @override + State<_MonthlyMenu> createState() => _MonthlyState(); +} + +class _MonthlyState extends State<_MonthlyMenu> { + DateTime _month = DateTime(DateTime.now().year, DateTime.now().month); + + @override + Widget build(BuildContext context) { + final sb = Supabase.instance.client; + final monthStr = DateFormat('yyyy-MM').format(_month); + final monthName = DateFormat('MMMM yyyy', 'pt_PT').format(_month); + + return Column(children: [ + Container( + color: _card, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + IconButton( + onPressed: () => setState(() => _month = DateTime(_month.year, _month.month - 1)), + icon: const Icon(Icons.chevron_left, color: _blue)), + Expanded(child: Text(monthName.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13, letterSpacing: 1))), + IconButton( + onPressed: () => setState(() => _month = DateTime(_month.year, _month.month + 1)), + icon: const Icon(Icons.chevron_right, color: _blue)), + ]), + ), + Expanded( + child: FutureBuilder>>( + future: sb.from('menu_items').select() + .like('week_start', '$monthStr%') + .order('week_start').order('day_index'), + builder: (ctx, snap) { + if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: _blue)); + if (snap.data!.isEmpty) return _emptyMenu(); + // Agrupa por semana + final Map>> byWeek = {}; + for (final item in snap.data!) { + final w = item['week_start'] as String; + byWeek.putIfAbsent(w, () => []).add(item); + } + return ListView( + padding: const EdgeInsets.all(14), + children: byWeek.entries.map((e) { + final weekDt = DateTime.parse(e.key); + final end = weekDt.add(const Duration(days: 4)); + return _WeekSummaryCard( + label: '${DateFormat('d', 'pt_PT').format(weekDt)}–${DateFormat('d MMM', 'pt_PT').format(end)}', + items: e.value, + ); + }).toList(), + ); + }, + ), + ), + ]); + } +} + +class _WeekSummaryCard extends StatelessWidget { + final String label; + final List> items; + const _WeekSummaryCard({required this.label, required this.items}); + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _card, borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withOpacity(0.07))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + const Icon(Icons.calendar_view_week, color: _blue, size: 16), + const SizedBox(width: 6), + Text('Semana de $label', style: const TextStyle(color: _blue, fontWeight: FontWeight.bold, fontSize: 13)), + ]), + const SizedBox(height: 8), + ...items.take(6).map((m) { + final day = (m['day_index'] as int?) ?? 0; + final meal = (m['meal_index'] as int?) ?? 0; + final dayName = day < _weekDays.length ? _weekDays[day] : ''; + final mealName = meal < _mealNames.length ? _mealNames[meal] : ''; + return Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text('• $dayName – $mealName: ${m['description'] ?? ''}', + style: const TextStyle(color: Color(0xFF888888), fontSize: 12)), + ); + }), + if (items.length > 6) + Text('+${items.length - 6} itens', style: TextStyle(color: Colors.white.withOpacity(0.2), fontSize: 11)), + ]), + ); +} + +// ── Formulário publicar cardápio (admin) ────────────────────────── +class _PublishMenuForm extends ConsumerStatefulWidget { + final DateTime week; + const _PublishMenuForm({required this.week}); + @override + ConsumerState<_PublishMenuForm> createState() => _PublishState(); +} + +class _PublishState extends ConsumerState<_PublishMenuForm> { + int _day = 0, _meal = 0; + final _descCtrl = TextEditingController(); + bool _saving = false; + + @override + void dispose() { _descCtrl.dispose(); super.dispose(); } + + Future _save() async { + if (_descCtrl.text.trim().isEmpty) return; + setState(() => _saving = true); + try { + final sb = Supabase.instance.client; + final weekStr = DateFormat('yyyy-MM-dd').format(widget.week); + await sb.from('menu_items').upsert({ + 'week_start': weekStr, + 'day_index': _day, + 'meal_index': _meal, + 'description': _descCtrl.text.trim(), + }, onConflict: 'week_start,day_index,meal_index'); + _descCtrl.clear(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Publicado! ✓', style: TextStyle(color: Colors.white)), + backgroundColor: _green, behavior: SnackBarBehavior.floating)); + } + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Erro: $e'), backgroundColor: _red, behavior: SnackBarBehavior.floating)); + } finally { if (mounted) setState(() => _saving = false); } + } + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.only(left: 20, right: 20, top: 20, bottom: MediaQuery.of(context).viewInsets.bottom + 20), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('Publicar Ementa', style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)), + Text('Semana: ${DateFormat('d MMM', 'pt_PT').format(widget.week)}', + style: const TextStyle(color: Color(0xFF888888), fontSize: 12)), + const SizedBox(height: 18), + // Dia + const Text('Dia', style: TextStyle(color: Color(0xFF888888), fontSize: 12)), + const SizedBox(height: 6), + SingleChildScrollView(scrollDirection: Axis.horizontal, + child: Row(children: List.generate(5, (i) => GestureDetector( + onTap: () => setState(() => _day = i), + child: Container( + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: _day == i ? _blue.withOpacity(0.2) : Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _day == i ? _blue : Colors.white.withOpacity(0.1))), + child: Text(_weekDays[i], style: TextStyle(color: _day == i ? _blue : Colors.white60, fontSize: 12)), + ), + ))), + ), + const SizedBox(height: 12), + // Refeição + const Text('Refeição', style: TextStyle(color: Color(0xFF888888), fontSize: 12)), + const SizedBox(height: 6), + SingleChildScrollView(scrollDirection: Axis.horizontal, + child: Row(children: List.generate(_mealNames.length, (i) => GestureDetector( + onTap: () => setState(() => _meal = i), + child: Container( + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _meal == i ? _amber.withOpacity(0.2) : Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _meal == i ? _amber : Colors.white.withOpacity(0.1))), + child: Row(children: [ + Icon(_mealIcons[i], size: 14, color: _meal == i ? _amber : Colors.white38), + const SizedBox(width: 4), + Text(_mealNames[i], style: TextStyle(color: _meal == i ? _amber : Colors.white60, fontSize: 12)), + ]), + ), + ))), + ), + const SizedBox(height: 14), + TextField( + controller: _descCtrl, style: const TextStyle(color: Colors.white), + maxLines: 2, + decoration: InputDecoration( + hintText: 'Ex: Arroz com frango e legumes, sumo natural', + hintStyle: const TextStyle(color: Color(0xFF555555), fontSize: 13), + filled: true, fillColor: Colors.white.withOpacity(0.04), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09))), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: _saving ? null : _save, + child: Container( + height: 50, width: double.infinity, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [_blue, Color(0xFF0288D1)]), + borderRadius: BorderRadius.circular(12)), + child: Center(child: _saving + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Text('Publicar', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15))), + ), + ), + ]), + ); +} + +Widget _emptyMenu() => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.restaurant_menu, size: 60, color: Colors.white.withOpacity(0.08)), + const SizedBox(height: 12), + const Text('Sem ementa publicada para esta semana', style: TextStyle(color: Color(0xFF888888), fontSize: 13)), + const SizedBox(height: 4), + const Text('A diretora ainda não publicou o cardápio.', style: TextStyle(color: Color(0xFF555555), fontSize: 11)), +])); + +Widget _err(String msg) => Center(child: Text(msg, style: const TextStyle(color: _red))); diff --git a/creche_app/lib/features/payments/payments_screen.dart b/creche_app/lib/features/payments/payments_screen.dart new file mode 100644 index 0000000..f3a1822 --- /dev/null +++ b/creche_app/lib/features/payments/payments_screen.dart @@ -0,0 +1,347 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; +import '/core/auth_provider.dart'; +import '/models/payment.dart'; +import '/models/child.dart'; +import '/models/profile.dart'; + +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); +const _green = Color(0xFF2ECC71); +const _amber = Color(0xFFFFB300); +const _red = Color(0xFFE74C3C); + +class PaymentsScreen extends ConsumerStatefulWidget { + const PaymentsScreen({super.key}); + @override + ConsumerState createState() => _State(); +} + +class _State extends ConsumerState { + bool _isAdmin = false; + + @override + void initState() { + super.initState(); + _checkRole(); + } + + Future _checkRole() async { + final p = await ref.read(currentProfileProvider.future); + if (mounted) setState(() => _isAdmin = p?.role == 'principal' || p?.role == 'admin'); + } + + @override + Widget build(BuildContext context) { + final sb = Supabase.instance.client; + + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, elevation: 0, + title: const Text('Mensalidades', style: TextStyle(color: _blue, fontWeight: FontWeight.bold)), + ), + body: StreamBuilder>>( + // Join com children para ter nomes + stream: sb.from('payments').stream(primaryKey: ['id']).order('month', ascending: false), + builder: (ctx, snap) { + if (snap.hasError) return Center(child: Text('Erro: ${snap.error}', style: const TextStyle(color: _red))); + if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: _blue)); + + final payments = snap.data!.map(Payment.fromMap).toList(); + + final paid = payments.where((p) => p.status == 'paid').length; + final pending = payments.where((p) => p.status == 'pending').length; + final overdue = payments.where((p) => p.status == 'overdue').length; + final total = payments.where((p) => p.status == 'paid') + .fold(0, (sum, p) => sum + p.amount); + + return Column(children: [ + // Resumo + Container( + color: _card, + padding: const EdgeInsets.all(16), + child: Column(children: [ + Row(children: [ + _SummaryTile(label: 'Pagos', count: paid, color: _green), + const SizedBox(width: 8), + _SummaryTile(label: 'Pendentes', count: pending, color: _amber), + const SizedBox(width: 8), + _SummaryTile(label: 'Atrasados', count: overdue, color: _red), + ]), + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration(color: _green.withOpacity(0.08), borderRadius: BorderRadius.circular(10), + border: Border.all(color: _green.withOpacity(0.2))), + child: Center(child: Text( + 'Total recebido: Kz ${NumberFormat('#,###.##').format(total)}', + style: const TextStyle(color: _green, fontWeight: FontWeight.bold, fontSize: 14))), + ), + ]), + ), + // Lista + Expanded(child: payments.isEmpty + ? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.payment, size: 60, color: Colors.white.withOpacity(0.08)), + const SizedBox(height: 10), + const Text('Sem mensalidades registadas', style: TextStyle(color: Color(0xFF888888))), + ])) + : ListView.builder( + padding: const EdgeInsets.all(14), + itemCount: payments.length, + itemBuilder: (_, i) => _PaymentCard( + payment: payments[i], isAdmin: _isAdmin, + onStatusChange: _isAdmin ? (p, status) => _updateStatus(p, status) : null, + ), + )), + ]); + }, + ), + floatingActionButton: _isAdmin ? FloatingActionButton.extended( + backgroundColor: _blue, + icon: const Icon(Icons.add, color: Colors.white), + label: const Text('Nova Mensalidade', style: TextStyle(color: Colors.white)), + onPressed: () => _showAddDialog(context), + ) : null, + ); + } + + Future _updateStatus(Payment p, String status) async { + final sb = Supabase.instance.client; + try { + await sb.from('payments').update({'status': status, 'paid_at': status == 'paid' ? DateTime.now().toIso8601String() : null}).eq('id', p.id); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro: $e'), backgroundColor: _red)); + } + } + + void _showAddDialog(BuildContext ctx) { + showModalBottomSheet( + context: ctx, isScrollControlled: true, backgroundColor: _card, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (_) => const _AddPaymentForm()); + } +} + +class _PaymentCard extends StatelessWidget { + final Payment payment; + final bool isAdmin; + final Function(Payment, String)? onStatusChange; + const _PaymentCard({required this.payment, required this.isAdmin, this.onStatusChange}); + + Color get _statusColor => switch (payment.status) { + 'paid' => _green, + 'overdue' => _red, + _ => _amber, + }; + + String get _statusLabel => switch (payment.status) { + 'paid' => 'Pago ✓', + 'overdue' => 'Em Atraso', + _ => 'Pendente', + }; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _getChildName(), + builder: (ctx, snap) { + final childName = snap.data ?? payment.childId.substring(0, 8) + '...'; + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _card, borderRadius: BorderRadius.circular(14), + border: Border.all(color: _statusColor.withOpacity(0.25)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: _statusColor.withOpacity(0.1), shape: BoxShape.circle), + child: Icon(Icons.receipt_long, color: _statusColor, size: 18), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(childName, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)), + Text(DateFormat('MMMM yyyy', 'pt_PT').format(payment.month), + style: const TextStyle(color: Color(0xFF888888), fontSize: 12)), + ])), + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text('Kz ${NumberFormat('#,###').format(payment.amount)}', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)), + Container( + margin: const EdgeInsets.only(top: 3), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: _statusColor.withOpacity(0.12), borderRadius: BorderRadius.circular(10)), + child: Text(_statusLabel, style: TextStyle(color: _statusColor, fontSize: 11)), + ), + ]), + ]), + if (isAdmin && payment.status != 'paid') ...[ + const SizedBox(height: 10), + Row(children: [ + Expanded(child: _ActionBtn( + label: '✓ Marcar Pago', color: _green, + onTap: () => onStatusChange?.call(payment, 'paid'))), + const SizedBox(width: 8), + Expanded(child: _ActionBtn( + label: '⚠ Marcar Atraso', color: _red, + onTap: () => onStatusChange?.call(payment, 'overdue'))), + ]), + ], + ]), + ); + }, + ); + } + + Future _getChildName() async { + try { + final sb = Supabase.instance.client; + final data = await sb.from('children').select('first_name,last_name') + .eq('id', payment.childId).maybeSingle(); + if (data == null) return 'Criança'; + return '${data['first_name']} ${data['last_name']}'; + } catch (_) { return 'Criança'; } + } +} + +class _ActionBtn extends StatelessWidget { + final String label; final Color color; final VoidCallback onTap; + const _ActionBtn({required this.label, required this.color, required this.onTap}); + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.3))), + child: Center(child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold))), + ), + ); +} + +class _SummaryTile extends StatelessWidget { + final String label; final int count; final Color color; + const _SummaryTile({required this.label, required this.count, required this.color}); + @override + Widget build(BuildContext context) => Expanded(child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration(color: color.withOpacity(0.08), borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.25))), + child: Column(children: [ + Text('$count', style: TextStyle(color: color, fontSize: 22, fontWeight: FontWeight.bold)), + Text(label, style: const TextStyle(color: Color(0xFF888888), fontSize: 11)), + ]), + )); +} + +class _AddPaymentForm extends ConsumerStatefulWidget { + const _AddPaymentForm(); + @override + ConsumerState<_AddPaymentForm> createState() => _AddState(); +} + +class _AddState extends ConsumerState<_AddPaymentForm> { + final _amountCtrl = TextEditingController(); + String? _childId; + DateTime _month = DateTime(DateTime.now().year, DateTime.now().month); + List _children = []; + bool _saving = false; + + @override + void initState() { super.initState(); _loadChildren(); } + @override + void dispose() { _amountCtrl.dispose(); super.dispose(); } + + Future _loadChildren() async { + final sb = Supabase.instance.client; + final data = await sb.from('children').select().order('first_name'); + if (mounted) setState(() => _children = data.map((d) => Child.fromMap(d)).toList()); + } + + Future _save() async { + if (_childId == null || _amountCtrl.text.trim().isEmpty) return; + setState(() => _saving = true); + try { + final sb = Supabase.instance.client; + await sb.from('payments').insert({ + 'id': const Uuid().v4(), + 'child_id': _childId, + 'guardian_id': _childId, // placeholder — adjust if you have guardian FK + 'month': DateFormat('yyyy-MM-01').format(_month), + 'amount': double.tryParse(_amountCtrl.text.replaceAll(',', '.')) ?? 0, + 'status': 'pending', + }); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Erro: $e'), backgroundColor: _red)); + } finally { if (mounted) setState(() => _saving = false); } + } + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.only(left: 20, right: 20, top: 20, bottom: MediaQuery.of(context).viewInsets.bottom + 20), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Text('Nova Mensalidade', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _childId, dropdownColor: _card, + style: const TextStyle(color: Colors.white), + decoration: _dec('Seleccionar criança', Icons.child_care), + items: _children.map((c) => DropdownMenuItem(value: c.id, + child: Text(c.fullName, style: const TextStyle(color: Colors.white)))).toList(), + onChanged: (v) => setState(() => _childId = v), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: () async { + final picked = await showDatePicker(context: context, + initialDate: _month, firstDate: DateTime(2020), lastDate: DateTime(2030), + builder: (ctx, child) => Theme(data: ThemeData.dark().copyWith( + colorScheme: const ColorScheme.dark(primary: _blue)), child: child!)); + if (picked != null) setState(() => _month = DateTime(picked.year, picked.month)); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration(color: Colors.white.withOpacity(0.04), borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.09))), + child: Row(children: [ + const Icon(Icons.calendar_month, color: _blue, size: 18), + const SizedBox(width: 10), + Text(DateFormat('MMMM yyyy', 'pt_PT').format(_month), + style: const TextStyle(color: Colors.white)), + ]), + ), + ), + const SizedBox(height: 12), + TextField(controller: _amountCtrl, keyboardType: TextInputType.number, + style: const TextStyle(color: Colors.white), + decoration: _dec('Valor (Kz)', Icons.attach_money)), + const SizedBox(height: 16), + GestureDetector(onTap: _saving ? null : _save, + child: Container(height: 48, width: double.infinity, + decoration: BoxDecoration(gradient: const LinearGradient(colors: [_blue, Color(0xFF0288D1)]), + borderRadius: BorderRadius.circular(12)), + child: Center(child: _saving + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Text('Criar Mensalidade', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold))))), + ]), + ); + + InputDecoration _dec(String hint, IconData icon) => InputDecoration( + hintText: hint, hintStyle: const TextStyle(color: Color(0xFF555555), fontSize: 13), + prefixIcon: Icon(icon, color: _blue.withOpacity(0.6), size: 18), + filled: true, fillColor: Colors.white.withOpacity(0.04), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09))), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ); +} diff --git a/creche_app/lib/features/profile/profile_screen.dart b/creche_app/lib/features/profile/profile_screen.dart new file mode 100644 index 0000000..3dcb651 --- /dev/null +++ b/creche_app/lib/features/profile/profile_screen.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:uuid/uuid.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '/core/auth_provider.dart'; +import '/core/supabase_client.dart'; +import '/shared/widgets/custom_button.dart'; + +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); + +String _roleLabel(String r) { + switch (r) { + case 'principal': return 'Diretora'; + case 'admin': return 'Administrador'; + case 'teacher': return 'Educadora'; + case 'staff': return 'Auxiliar'; + case 'parent': return 'Encarregado'; + default: return r; + } +} + +Color _roleColor(String r) { + switch (r) { + case 'principal': return const Color(0xFFFFD700); + case 'admin': return const Color(0xFFFF7043); + case 'teacher': return _blue; + case 'staff': return const Color(0xFFA5D6A7); + case 'parent': return const Color(0xFFFFB300); + default: return Colors.grey; + } +} + +class ProfileScreen extends ConsumerStatefulWidget { + const ProfileScreen({super.key}); + @override + ConsumerState createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends ConsumerState { + final _nameCtrl = TextEditingController(); + final _phoneCtrl = TextEditingController(); + final _newPassCtrl = TextEditingController(); + final _confPassCtrl = TextEditingController(); + bool _isSaving = false; + bool _changingPw = false; + bool _showPwForm = false; + bool _obscureNew = true; + + @override + void dispose() { + _nameCtrl.dispose(); _phoneCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final profileAsync = ref.watch(currentProfileProvider); + return profileAsync.when( + data: (profile) { + if (profile == null) { + return const Scaffold(backgroundColor: _bg, + body: Center(child: Text('Perfil não encontrado', style: TextStyle(color: Colors.white)))); + } + // só preenche se vazio (evita reset ao rebuild) + if (_nameCtrl.text.isEmpty) _nameCtrl.text = profile.fullName; + if (_phoneCtrl.text.isEmpty) _phoneCtrl.text = profile.phone ?? ''; + final roleColor = _roleColor(profile.role); + + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, + title: const Text('O meu perfil', style: TextStyle(color: _blue, fontWeight: FontWeight.bold)), + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column(children: [ + + // ── Avatar ───────────────────────────────────────── + Center(child: Stack(children: [ + Container( + width: 100, height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: roleColor.withOpacity(0.5), width: 2.5), + color: roleColor.withOpacity(0.1), + ), + child: profile.avatarUrl != null + ? ClipOval(child: Image.network(profile.avatarUrl!, fit: BoxFit.cover)) + : Center(child: Text( + profile.fullName.isNotEmpty ? profile.fullName[0].toUpperCase() : 'U', + style: TextStyle(color: roleColor, fontSize: 38, fontWeight: FontWeight.bold))), + ), + Positioned(bottom: 0, right: 0, child: GestureDetector( + onTap: () => _pickAvatar(profile.id), + child: Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration(color: _blue, shape: BoxShape.circle, + border: Border.all(color: _bg, width: 2)), + child: const Icon(Icons.camera_alt, color: Colors.white, size: 16), + ), + )), + ])), + const SizedBox(height: 12), + + // Role badge (só visualização — não pode mudar) + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: roleColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: roleColor.withOpacity(0.3)), + ), + child: Text(_roleLabel(profile.role).toUpperCase(), + style: TextStyle(color: roleColor, fontSize: 11, fontWeight: FontWeight.bold, letterSpacing: 1.2)), + ), + const SizedBox(height: 6), + Text('A tua função é atribuída pela Diretora', + style: TextStyle(color: Colors.white.withOpacity(0.3), fontSize: 11)), + const SizedBox(height: 28), + + // ── Dados pessoais ───────────────────────────────── + _Section(title: 'Dados Pessoais', children: [ + _Field(ctrl: _nameCtrl, label: 'Nome completo', icon: Icons.person_outline), + const SizedBox(height: 14), + _Field(ctrl: _phoneCtrl, label: 'Telefone', icon: Icons.phone_outlined, + type: TextInputType.phone), + const SizedBox(height: 14), + // Email — só leitura + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.06)), + ), + child: Row(children: [ + Icon(Icons.alternate_email, color: _blue.withOpacity(0.5), size: 19), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Email', style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 11)), + const SizedBox(height: 2), + Text(Supabase.instance.client.auth.currentUser?.email ?? '', + style: const TextStyle(color: Colors.white70, fontSize: 14)), + ])), + const Icon(Icons.lock_outline, color: Colors.white24, size: 14), + ]), + ), + const SizedBox(height: 20), + CustomButton(text: 'Guardar Alterações', isLoading: _isSaving, + onPressed: () => _save(profile.id), icon: Icons.save_outlined), + ]), + const SizedBox(height: 16), + + // ── Alterar Senha ────────────────────────────────── + _Section( + title: 'Segurança', + trailing: TextButton( + onPressed: () => setState(() => _showPwForm = !_showPwForm), + child: Text(_showPwForm ? 'Cancelar' : 'Alterar senha', + style: const TextStyle(color: _blue, fontSize: 12)), + ), + children: [ + if (!_showPwForm) + Text('Podes alterar a tua senha a qualquer momento.', + style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 13)) + else ...[ + _Field(ctrl: _newPassCtrl, label: 'Nova senha', icon: Icons.lock_outline, + obscure: _obscureNew, + suffix: IconButton( + icon: Icon(_obscureNew ? Icons.visibility_off : Icons.visibility, + color: Colors.white38, size: 18), + onPressed: () => setState(() => _obscureNew = !_obscureNew), + )), + const SizedBox(height: 12), + _Field(ctrl: _confPassCtrl, label: 'Confirmar nova senha', icon: Icons.lock_outline, + obscure: _obscureNew), + const SizedBox(height: 16), + CustomButton(text: 'Actualizar Senha', isLoading: _changingPw, + onPressed: _changePassword, icon: Icons.security), + ], + ], + ), + const SizedBox(height: 16), + + // ── Sair ─────────────────────────────────────────── + GestureDetector( + onTap: () async { + await ref.read(authNotifierProvider.notifier).signOut(); + if (context.mounted) context.go('/login'); + }, + child: Container( + height: 50, width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.red.withOpacity(0.4)), + color: Colors.red.withOpacity(0.06), + ), + child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.logout, color: Colors.red, size: 18), + SizedBox(width: 8), + Text('Terminar Sessão', style: TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.w500)), + ]), + ), + ), + const SizedBox(height: 32), + ]), + ), + ); + }, + loading: () => const Scaffold(backgroundColor: _bg, + body: Center(child: CircularProgressIndicator(color: _blue))), + error: (e, _) => Scaffold(backgroundColor: _bg, + body: Center(child: Text('Erro: $e', style: const TextStyle(color: Colors.red)))), + ); + } + + Future _pickAvatar(String profileId) async { + final picker = ImagePicker(); + final file = await picker.pickImage(source: ImageSource.gallery, imageQuality: 70); + if (file == null) return; + final supabase = ref.read(supabaseProvider); + final bytes = await file.readAsBytes(); + final path = 'avatars/${const Uuid().v4()}.jpg'; + await supabase.storage.from('photos').uploadBinary(path, bytes); + final url = supabase.storage.from('photos').getPublicUrl(path); + await supabase.from('profiles').update({'avatar_url': url}).eq('id', profileId); + ref.invalidate(currentProfileProvider); + } + + Future _save(String profileId) async { + setState(() => _isSaving = true); + try { + final supabase = ref.read(supabaseProvider); + await supabase.from('profiles').update({ + 'full_name': _nameCtrl.text.trim(), + 'phone': _phoneCtrl.text.trim(), + // NÃO inclui 'role' — utilizador não pode mudar o próprio role + }).eq('id', profileId); + ref.invalidate(currentProfileProvider); + if (mounted) _snack('Perfil actualizado! ✓', ok: true); + } catch (e) { + if (mounted) _snack('Erro: $e'); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + Future _changePassword() async { + final newPass = _newPassCtrl.text; + final confPass = _confPassCtrl.text; + if (newPass.length < 6) { _snack('A senha deve ter pelo menos 6 caracteres.'); return; } + if (newPass != confPass) { _snack('As senhas não coincidem.'); return; } + + setState(() => _changingPw = true); + try { + await Supabase.instance.client.auth.updateUser(UserAttributes(password: newPass)); + _newPassCtrl.clear(); + _confPassCtrl.clear(); + setState(() => _showPwForm = false); + _snack('Senha alterada com sucesso! ✓', ok: true); + } catch (e) { + _snack('Erro ao alterar senha: $e'); + } finally { + if (mounted) setState(() => _changingPw = false); + } + } + + void _snack(String msg, {bool ok = false}) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg, style: const TextStyle(color: Colors.white)), + backgroundColor: ok ? const Color(0xFF2ECC71) : Colors.red, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + )); + } +} + +class _Section extends StatelessWidget { + final String title; + final Widget? trailing; + final List children; + const _Section({required this.title, required this.children, this.trailing}); + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: _card, borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.07)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(title, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold)), + const Spacer(), + if (trailing != null) trailing!, + ]), + const SizedBox(height: 14), + ...children, + ]), + ); +} + +class _Field extends StatelessWidget { + final TextEditingController ctrl; + final String label; + final IconData icon; + final bool obscure; + final TextInputType type; + final Widget? suffix; + const _Field({required this.ctrl, required this.label, required this.icon, + this.obscure = false, this.type = TextInputType.text, this.suffix}); + @override + Widget build(BuildContext context) => TextField( + controller: ctrl, obscureText: obscure, keyboardType: type, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 13), + prefixIcon: Icon(icon, color: _blue.withOpacity(0.7), size: 19), + suffixIcon: suffix, + filled: true, fillColor: Colors.white.withOpacity(0.04), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _blue, width: 1.5)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ); +} diff --git a/creche_app/lib/features/settings/settings_screen.dart b/creche_app/lib/features/settings/settings_screen.dart new file mode 100644 index 0000000..689ebaf --- /dev/null +++ b/creche_app/lib/features/settings/settings_screen.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '/models/creche_settings.dart'; + +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); +const _green = Color(0xFF2ECC71); +const _red = Color(0xFFE74C3C); + +class SettingsScreen extends ConsumerStatefulWidget { + const SettingsScreen({super.key}); + @override + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + final _nameCtrl = TextEditingController(); + final _addrCtrl = TextEditingController(); + final _slogCtrl = TextEditingController(); + final _latCtrl = TextEditingController(); + final _lngCtrl = TextEditingController(); + final _radCtrl = TextEditingController(); + final _ipCtrl = TextEditingController(); + List _ips = []; + bool _loading = true; + bool _saving = false; + String? _error; + + @override + void initState() { super.initState(); _load(); } + + @override + void dispose() { + _nameCtrl.dispose(); _addrCtrl.dispose(); _slogCtrl.dispose(); + _latCtrl.dispose(); _lngCtrl.dispose(); _radCtrl.dispose(); _ipCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { _loading = true; _error = null; }); + try { + final sb = Supabase.instance.client; + var data = await sb.from('creche_settings').select().limit(1).maybeSingle(); + + if (data == null) { + // Criar linha de configurações default + await sb.from('creche_settings').upsert({ + 'id': 1, + 'name': 'Creche e Berçário Sementes do Futuro', + 'slogan': 'Conforto, cuidado e aprendizagem', + 'geofence_radius_meters': 150, + 'allowed_ips': [], + }); + data = await sb.from('creche_settings').select().eq('id', 1).maybeSingle(); + } + + if (data != null) { + final s = CrecheSettings.fromMap(data); + _nameCtrl.text = s.name; + _addrCtrl.text = s.address ?? ''; + _slogCtrl.text = s.slogan; + _latCtrl.text = s.geofenceLat?.toString() ?? ''; + _lngCtrl.text = s.geofenceLng?.toString() ?? ''; + _radCtrl.text = s.geofenceRadiusMeters.toString(); + _ips = List.from(s.allowedIps); + } + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _save() async { + setState(() { _saving = true; _error = null; }); + try { + await Supabase.instance.client.from('creche_settings').upsert({ + 'id': 1, + 'name': _nameCtrl.text.trim(), + 'address': _addrCtrl.text.trim().isEmpty ? null : _addrCtrl.text.trim(), + 'slogan': _slogCtrl.text.trim(), + 'geofence_lat': double.tryParse(_latCtrl.text), + 'geofence_lng': double.tryParse(_lngCtrl.text), + 'geofence_radius_meters': int.tryParse(_radCtrl.text) ?? 150, + 'allowed_ips': _ips, + }); + if (mounted) _snack('Configurações guardadas! ✓', ok: true); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + void _addIp() { + final ip = _ipCtrl.text.trim(); + if (ip.isNotEmpty && !_ips.contains(ip)) { + setState(() { _ips.add(ip); _ipCtrl.clear(); }); + } + } + + void _snack(String msg, {bool ok = false}) => ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg, style: const TextStyle(color: Colors.white)), + backgroundColor: ok ? _green : _red, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + )); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, elevation: 0, + title: const Text('Configurações', style: TextStyle(color: _blue, fontWeight: FontWeight.bold)), + actions: [ + IconButton(icon: const Icon(Icons.refresh, color: _blue), onPressed: _load), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator(color: _blue)) + : _error != null + ? _buildError() + : _buildForm(), + ); + } + + // ── Error inline (sem widget separado que pode ter layout issues) ── + Widget _buildError() => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SizedBox(height: 40), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: _red.withOpacity(0.08), borderRadius: BorderRadius.circular(16), + border: Border.all(color: _red.withOpacity(0.3))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Row(children: [ + Icon(Icons.error_outline, color: _red, size: 22), + SizedBox(width: 8), + Expanded(child: Text('Erro ao carregar configurações', + style: TextStyle(color: _red, fontWeight: FontWeight.bold))), + ]), + const SizedBox(height: 10), + Text(_error!, style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 11, fontFamily: 'monospace')), + ]), + ), + const SizedBox(height: 16), + // Diagnóstico + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.orange.withOpacity(0.3))), + child: const Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('🔒 Possível causa: RLS em falta', + style: TextStyle(color: Colors.orange, fontWeight: FontWeight.bold)), + SizedBox(height: 8), + Text('Corre o ficheiro FIX_COMPLETO_V3.sql no Supabase SQL Editor.', + style: TextStyle(color: Color(0xFFAAAAAA), fontSize: 12, height: 1.5)), + ]), + ), + const SizedBox(height: 24), + // Botão com constraints explícitas — evita layout error + SizedBox( + width: double.infinity, height: 50, + child: ElevatedButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Tentar novamente'), + style: ElevatedButton.styleFrom( + backgroundColor: _blue, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ]), + ); + + Widget _buildForm() => SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + + // ── DADOS DA CRECHE ────────────────────────────────────── + _sec('🏫', 'Dados da Creche'), + const SizedBox(height: 14), + _field(_nameCtrl, 'Nome da Creche', Icons.business), + const SizedBox(height: 12), + _field(_addrCtrl, 'Endereço completo', Icons.location_city), + const SizedBox(height: 12), + _field(_slogCtrl, 'Slogan', Icons.format_quote), + const SizedBox(height: 28), + + // ── GEOFENCE ───────────────────────────────────────────── + _sec('📍', 'Geofence — Área de acesso'), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _blue.withOpacity(0.06), borderRadius: BorderRadius.circular(10), + border: Border.all(color: _blue.withOpacity(0.2))), + child: const Text( + 'Define a área onde os funcionários podem fazer login.\nDeixa em branco para desactivar o geofence.', + style: TextStyle(color: Color(0xFF888888), fontSize: 12, height: 1.5)), + ), + const SizedBox(height: 14), + Row(children: [ + Expanded(child: _field(_latCtrl, 'Latitude', Icons.explore, type: TextInputType.number)), + const SizedBox(width: 12), + Expanded(child: _field(_lngCtrl, 'Longitude', Icons.explore, type: TextInputType.number)), + ]), + const SizedBox(height: 12), + _field(_radCtrl, 'Raio em metros (ex: 150)', Icons.radar, type: TextInputType.number), + const SizedBox(height: 28), + + // ── IPs PERMITIDOS ──────────────────────────────────────── + _sec('🔒', 'IPs Permitidos'), + const SizedBox(height: 8), + const Text('Restringe o login a IPs específicos. Deixa vazio para não restringir.', + style: TextStyle(color: Color(0xFF888888), fontSize: 12)), + const SizedBox(height: 12), + if (_ips.isNotEmpty) ...[ + Wrap( + spacing: 8, runSpacing: 8, + children: _ips.map((ip) => Chip( + label: Text(ip, style: const TextStyle(color: Colors.white, fontSize: 12)), + backgroundColor: const Color(0xFF1C2233), + side: BorderSide(color: _blue.withOpacity(0.4)), + deleteIcon: const Icon(Icons.close, size: 14, color: _red), + onDeleted: () => setState(() => _ips.remove(ip)), + )).toList(), + ), + const SizedBox(height: 12), + ], + // Row com TextField + botão — usando IntrinsicHeight para evitar layout issues + Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Expanded( + child: TextField( + controller: _ipCtrl, + style: const TextStyle(color: Colors.white, fontSize: 14), + onSubmitted: (_) => _addIp(), + decoration: InputDecoration( + hintText: 'Ex: 192.168.1.1', + hintStyle: const TextStyle(color: Color(0xFF555577), fontSize: 13), + prefixIcon: const Icon(Icons.lan, color: _blue, size: 20), + filled: true, fillColor: _card, + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.white.withOpacity(0.1))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: _blue)), + contentPadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + ), + ), + ), + const SizedBox(width: 10), + // SizedBox explícito evita o bug w=Infinity no ElevatedButton dentro de Row + SizedBox( + height: 50, width: 80, + child: ElevatedButton( + onPressed: _addIp, + style: ElevatedButton.styleFrom( + backgroundColor: _blue, padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text('+ Add', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + ), + ), + ]), + const SizedBox(height: 36), + + // ── BOTÃO GUARDAR ───────────────────────────────────────── + // SizedBox com width explícita previne BoxConstraints(w=Infinity) + SizedBox( + width: double.infinity, height: 54, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient(colors: _saving + ? [const Color(0xFF1A3A4A), const Color(0xFF1A3A4A)] + : [_blue, const Color(0xFF0288D1)]), + borderRadius: BorderRadius.circular(14), + boxShadow: _saving ? [] : [BoxShadow(color: _blue.withOpacity(0.25), blurRadius: 16, offset: const Offset(0, 6))], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: _saving ? null : _save, + child: Center(child: _saving + ? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) + : const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.save_outlined, color: Colors.white, size: 20), + SizedBox(width: 10), + Text('Guardar Configurações', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)), + ]), + ), + ), + ), + ), + + )]), + ); + + Widget _sec(String icon, String title) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row(children: [ + Text(icon, style: const TextStyle(fontSize: 20)), + const SizedBox(width: 10), + Text(title, style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)), + ]), + ); + + Widget _field(TextEditingController c, String label, IconData icon, {TextInputType type = TextInputType.text}) => + TextField( + controller: c, keyboardType: type, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF888888), fontSize: 13), + prefixIcon: Icon(icon, color: _blue, size: 20), + filled: true, fillColor: _card, + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withOpacity(0.1))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _blue, width: 1.5)), + ), + ); +} diff --git a/creche_app/lib/features/splash/splash_screen.dart b/creche_app/lib/features/splash/splash_screen.dart new file mode 100644 index 0000000..e784bf4 --- /dev/null +++ b/creche_app/lib/features/splash/splash_screen.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lottie/lottie.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '/core/auth_provider.dart'; +import '/models/invite.dart'; +import '/features/auth/invite_pending_screen.dart'; + +class SplashScreen extends ConsumerStatefulWidget { + const SplashScreen({super.key}); + @override + ConsumerState createState() => _SplashScreenState(); +} + +class _SplashScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2)); + _ctrl.forward(); + Future.delayed(const Duration(milliseconds: 2200), _navigate); + } + + Future _navigate() async { + if (!mounted) return; + try { + await ref.read(authNotifierProvider.future); + final session = await ref.read(currentSessionProvider.future); + if (!mounted) return; + + if (session == null) { context.go('/login'); return; } + + // ─── Verificar se o utilizador tem perfil + final supabase = Supabase.instance.client; + final profile = await supabase + .from('profiles') + .select() + .eq('user_id', session.user.id) + .maybeSingle(); + + // ─── Verificar convite pendente pelo email + final email = session.user.email ?? ''; + final inviteData = await supabase + .from('invites') + .select() + .eq('email', email) + .eq('status', 'pending') + .gt('expires_at', DateTime.now().toIso8601String()) + .order('created_at', ascending: false) + .limit(1) + .maybeSingle(); + + if (!mounted) return; + + if (inviteData != null) { + final invite = Invite.fromMap(inviteData); + if (!invite.isExpired) { + // Mostrar ecrã de aceitação de convite + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => InvitePendingScreen(invite: invite)), + ); + return; + } + } + + if (profile == null) { + // Sem perfil e sem convite → ecrã de espera / registo incompleto + context.go('/login'); + } else { + context.go('/home'); + } + } catch (_) { + if (mounted) context.go('/login'); + } + } + + @override + void dispose() { _ctrl.dispose(); super.dispose(); } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0D1117), + body: Stack(children: [ + // Orbs + Positioned(top: -100, right: -80, + child: _orb(300, const Color(0xFF4FC3F7).withOpacity(0.08))), + Positioned(bottom: -80, left: -60, + child: _orb(250, const Color(0xFFA5D6A7).withOpacity(0.06))), + SafeArea( + child: Center( + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + // Logo com glow + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient(colors: [ + const Color(0xFF4FC3F7).withOpacity(0.15), + Colors.transparent, + ]), + border: Border.all(color: const Color(0xFF4FC3F7).withOpacity(0.2), width: 1.5), + ), + child: Image.asset('assets/logo.png', height: 100, + errorBuilder: (_, __, ___) => const Icon(Icons.child_care, size: 80, color: Color(0xFF4FC3F7))), + ), + const SizedBox(height: 24), + const Text('SEMENTES DO FUTURO', + style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w900, letterSpacing: 2)), + const SizedBox(height: 6), + const Text('Diário do Candengue', + style: TextStyle(color: Color(0xFF4FC3F7), fontSize: 13, letterSpacing: 1.5)), + const SizedBox(height: 48), + Lottie.asset('assets/splash_animation.json', + controller: _ctrl, height: 80, repeat: false, + errorBuilder: (_, __, ___) => const SizedBox( + height: 40, width: 40, + child: CircularProgressIndicator(color: Color(0xFF4FC3F7), strokeWidth: 2), + )), + const SizedBox(height: 16), + Text('"Conforto, cuidado e aprendizagem"', + style: TextStyle(color: Colors.white.withOpacity(0.3), fontSize: 12, fontStyle: FontStyle.italic)), + ]), + ), + ), + ]), + ); + } + + Widget _orb(double size, Color color) => Container(width: size, height: size, + decoration: BoxDecoration(shape: BoxShape.circle, color: color, + boxShadow: [BoxShadow(color: color, blurRadius: size / 2)])); +} diff --git a/creche_app/lib/features/users/users_management_screen.dart b/creche_app/lib/features/users/users_management_screen.dart new file mode 100644 index 0000000..b940326 --- /dev/null +++ b/creche_app/lib/features/users/users_management_screen.dart @@ -0,0 +1,820 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; +import '/core/auth_provider.dart'; +import '/models/profile.dart'; +import '/models/daily_access_approval.dart'; +import '/models/invite.dart'; + +// ── Paleta ──────────────────────────────────────────────────────────── +const _bg = Color(0xFF0D1117); +const _card = Color(0xFF161B22); +const _blue = Color(0xFF4FC3F7); +const _green = Color(0xFF2ECC71); +const _amber = Color(0xFFFFB300); +const _red = Color(0xFFE74C3C); + +// ── Role helpers ────────────────────────────────────────────────────── +Color _rc(String r) { + switch (r) { + case 'principal': return const Color(0xFFFFD700); + case 'admin': return const Color(0xFFFF7043); + case 'teacher': return _blue; + case 'staff': return const Color(0xFFA5D6A7); + case 'parent': return _amber; + default: return const Color(0xFF666688); + } +} + +String _rl(String r) { + switch (r) { + case 'principal': return 'Diretora'; + case 'admin': return 'Admin'; + case 'teacher': return 'Educadora'; + case 'staff': return 'Auxiliar'; + case 'parent': return 'Encarregado'; + default: return r; + } +} + +const _rolesMeta = [ + {'value': 'teacher', 'label': '👩‍🏫 Educadora', 'desc': 'Turmas, diários, presenças'}, + {'value': 'staff', 'label': '🧹 Auxiliar', 'desc': 'Acesso operacional básico'}, + {'value': 'admin', 'label': '⚙️ Administrador', 'desc': 'Pagamentos e relatórios'}, + {'value': 'parent', 'label': '👨‍👧 Encarregado', 'desc': 'Só diário do(s) filho(s)'}, +]; + +// ═════════════════════════════════════════════════════════════════════ +// SCREEN +// ═════════════════════════════════════════════════════════════════════ +class UsersManagementScreen extends ConsumerStatefulWidget { + const UsersManagementScreen({super.key}); + @override + ConsumerState createState() => _ScreenState(); +} + +class _ScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tab; + + @override + void initState() { super.initState(); _tab = TabController(length: 4, vsync: this); } + @override + void dispose() { _tab.dispose(); super.dispose(); } + + @override + Widget build(BuildContext context) { + final myRole = ref.watch(currentProfileProvider).valueOrNull?.role ?? ''; + final canManage = myRole == 'principal' || myRole == 'admin'; + + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + backgroundColor: _card, elevation: 0, + title: const Text('Utilizadores', style: TextStyle(color: _blue, fontWeight: FontWeight.bold, fontSize: 18)), + bottom: TabBar( + controller: _tab, + indicatorColor: _blue, indicatorWeight: 3, + labelColor: _blue, unselectedLabelColor: const Color(0xFF555577), + labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 11), + isScrollable: true, tabAlignment: TabAlignment.start, + tabs: const [ + Tab(icon: Icon(Icons.people, size: 17), text: 'Equipa'), + Tab(icon: Icon(Icons.schedule, size: 17), text: 'Acessos'), + Tab(icon: Icon(Icons.mail_outline, size: 17), text: 'Convites'), + Tab(icon: Icon(Icons.person_add, size: 17), text: 'Convidar'), + ], + ), + ), + body: TabBarView( + controller: _tab, + children: [ + _TeamTab(canManage: canManage), + const _AccessesTab(), + _InvitesTab(canManage: canManage), + _SendInviteTab(onSent: () => _tab.animateTo(2)), + ], + ), + ); + } +} + +// ═════════════════════════════════════════════════════════════════════ +// TAB 1 — EQUIPA +// ═════════════════════════════════════════════════════════════════════ +class _TeamTab extends ConsumerWidget { + final bool canManage; + const _TeamTab({required this.canManage}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sb = Supabase.instance.client; + final myUid = sb.auth.currentUser?.id; + return StreamBuilder>>( + stream: sb.from('profiles').stream(primaryKey: ['id']), + builder: (ctx, snap) { + if (!snap.hasData) return const _Loader(); + final order = ['principal','admin','teacher','staff','parent']; + final list = snap.data!.map(Profile.fromMap).toList() + ..sort((a,b) => order.indexOf(a.role).compareTo(order.indexOf(b.role))); + final groups = >{}; + for (final p in list) (groups[p.role] ??= []).add(p); + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + children: [ + _StatsRow(profiles: list), + const SizedBox(height: 20), + for (final role in order) + if (groups[role] != null) ...[ + _SecHeader(label: _rl(role), color: _rc(role), count: groups[role]!.length), + ...groups[role]!.map((p) => _UserTile( + profile: p, isSelf: p.userId == myUid, + canDelete: canManage && p.role != 'principal' && p.userId != myUid, + canChangeRole: canManage && p.role != 'principal', + onDelete: () => _deleteDialog(ctx, ref, p, sb), + onChangeRole: () => _roleDialog(ctx, p, sb), + )), + const SizedBox(height: 6), + ], + ], + ); + }, + ); + } + + // ── Diálogo apagar ─────────────────────────────────────────────── + void _deleteDialog(BuildContext ctx, WidgetRef ref, Profile p, SupabaseClient sb) { + showDialog( + context: ctx, + builder: (_) => AlertDialog( + backgroundColor: _card, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Row(children: [ + Icon(Icons.warning_amber_rounded, color: _red, size: 24), + SizedBox(width: 10), + Text('Remover utilizador', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), + ]), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + RichText(text: TextSpan(style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 14, height: 1.6), children: [ + const TextSpan(text: 'Vais remover '), + TextSpan(text: p.fullName, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + const TextSpan(text: ' ('), + TextSpan(text: _rl(p.role), style: TextStyle(color: _rc(p.role))), + const TextSpan(text: ') do sistema.\nEsta acção é irreversível.'), + ])), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: _red.withOpacity(0.07), borderRadius: BorderRadius.circular(10), + border: Border.all(color: _red.withOpacity(0.3))), + child: const Text('⚠️ O perfil será apagado. Para apagar também a conta de autenticação vai ao painel Supabase → Authentication → Users.', + style: TextStyle(color: _red, fontSize: 11, height: 1.5)), + ), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar', style: TextStyle(color: Color(0xFF888888)))), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: _red, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), + onPressed: () async { + Navigator.pop(ctx); + try { + // 1. Remove child_guardians links + try { await sb.from('child_guardians').delete().eq('guardian_id', p.id); } catch (_) {} + // 2. Nullify invited_by on invites (can't delete — RLS only allows admin by id) + try { await sb.from('invites').update({'invited_by': null}).eq('invited_by', p.id); } catch (_) {} + // 3. Remove daily access requests + try { await sb.from('daily_access_approvals').delete().eq('user_id', p.id); } catch (_) {} + // 4. Remove the profile (this triggers cascade) + await sb.from('profiles').delete().eq('id', p.id); + // O utilizador perde o acesso imediatamente: + // sem perfil → authorize() retorna false → todas as queries falham → é redirecionado para login + if (ctx.mounted) _snack(ctx, '${p.fullName} removido. O acesso foi revogado.', ok: true); + } catch (e) { + if (ctx.mounted) _snack(ctx, 'Erro: ${e.toString().replaceAll('Exception: ', '')}'); + } + }, + child: const Text('Remover', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + + // ── Diálogo alterar role ───────────────────────────────────────── + void _roleDialog(BuildContext ctx, Profile p, SupabaseClient sb) { + // Diretora não pode ter o role alterado por ninguém (excepto no SQL) + if (p.role == 'principal') { + ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar( + content: Text('A função da Diretora não pode ser alterada aqui.'), + backgroundColor: Color(0xFFE74C3C), behavior: SnackBarBehavior.floating)); + return; + } + // Admin não pode alterar o próprio role + final currentUser = sb.auth.currentUser; + if (currentUser != null && p.userId == currentUser.id) { + ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar( + content: Text('Não podes alterar a tua própria função.'), + backgroundColor: Color(0xFFE74C3C), behavior: SnackBarBehavior.floating)); + return; + } + String picked = p.role; + showDialog( + context: ctx, + builder: (_) => StatefulBuilder( + builder: (_, set) => AlertDialog( + backgroundColor: _card, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Função de ${p.fullName.split(' ').first}', + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)), + content: Column(mainAxisSize: MainAxisSize.min, + children: _rolesMeta.map((r) { + final sel = picked == r['value']; + final c = _rc(r['value']!); + return GestureDetector( + onTap: () => set(() => picked = r['value']!), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: sel ? c.withOpacity(0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: sel ? c : Colors.white.withOpacity(0.08)), + ), + child: Row(children: [ + Expanded(child: Text(r['label']!, style: TextStyle(color: sel ? c : Colors.white, fontWeight: FontWeight.w600, fontSize: 13))), + if (sel) Icon(Icons.check_circle, color: c, size: 18), + ]), + ), + ); + }).toList(), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar', style: TextStyle(color: Color(0xFF888888)))), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: _blue, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), + onPressed: () async { + Navigator.pop(ctx); + if (picked == p.role) return; + await sb.from('profiles').update({'role': picked}).eq('id', p.id); + if (ctx.mounted) _snack(ctx, 'Função actualizada para ${_rl(picked)}.', ok: true); + }, + child: const Text('Guardar', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ); + } +} + +class _StatsRow extends StatelessWidget { + final List profiles; + const _StatsRow({required this.profiles}); + @override + Widget build(BuildContext context) { + final c = {}; + for (final p in profiles) c[p.role] = (c[p.role] ?? 0) + 1; + return Row(children: [ + for (final t in [('principal','👑','Dir.'),('teacher','👩‍🏫','Educ.'),('staff','🧹','Aux.'),('parent','👨‍👧','Enc.')]) + Expanded(child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(12), border: Border.all(color: _rc(t.$1).withOpacity(0.2))), + child: Column(children: [ + Text(t.$2, style: const TextStyle(fontSize: 16)), + Text('${c[t.$1] ?? 0}', style: TextStyle(color: _rc(t.$1), fontWeight: FontWeight.bold, fontSize: 16)), + Text(t.$3, style: const TextStyle(color: Color(0xFF666688), fontSize: 9)), + ]), + )), + ]); + } +} + +class _SecHeader extends StatelessWidget { + final String label; final Color color; final int count; + const _SecHeader({required this.label, required this.color, required this.count}); + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 8, top: 4), + child: Row(children: [ + Container(width: 3, height: 16, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text(label.toUpperCase(), style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 11, letterSpacing: 1)), + const SizedBox(width: 8), + Container(padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration(color: color.withOpacity(0.12), borderRadius: BorderRadius.circular(10)), + child: Text('$count', style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.bold))), + ]), + ); +} + +class _UserTile extends StatelessWidget { + final Profile profile; + final bool isSelf, canDelete, canChangeRole; + final VoidCallback onDelete, onChangeRole; + const _UserTile({required this.profile, required this.isSelf, required this.canDelete, + required this.canChangeRole, required this.onDelete, required this.onChangeRole}); + + @override + Widget build(BuildContext context) { + final c = _rc(profile.role); + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), + border: Border.all(color: isSelf ? _blue.withOpacity(0.5) : c.withOpacity(0.12))), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row(children: [ + // Avatar + Stack(children: [ + CircleAvatar(radius: 24, backgroundColor: c.withOpacity(0.12), + backgroundImage: profile.avatarUrl != null ? NetworkImage(profile.avatarUrl!) : null, + child: profile.avatarUrl == null + ? Text(profile.fullName.isNotEmpty ? profile.fullName[0].toUpperCase() : '?', + style: TextStyle(color: c, fontWeight: FontWeight.bold, fontSize: 18)) : null), + if (isSelf) Positioned(bottom: 0, right: 0, child: Container(width: 11, height: 11, + decoration: BoxDecoration(color: _green, shape: BoxShape.circle, border: Border.all(color: _card, width: 2)))), + ]), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Expanded(child: Text(profile.fullName, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14), + overflow: TextOverflow.ellipsis)), + if (isSelf) Container(margin: const EdgeInsets.only(left: 5), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: _blue.withOpacity(0.15), borderRadius: BorderRadius.circular(8)), + child: const Text('eu', style: TextStyle(color: _blue, fontSize: 10, fontWeight: FontWeight.bold))), + ]), + Text(profile.phone ?? 'Sem telefone', + style: const TextStyle(color: Color(0xFF666688), fontSize: 12)), + ])), + const SizedBox(width: 8), + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + // Badge role (clicável para mudar) + GestureDetector( + onTap: canChangeRole ? onChangeRole : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration(color: c.withOpacity(0.12), borderRadius: BorderRadius.circular(20), + border: canChangeRole ? Border.all(color: c.withOpacity(0.35)) : null), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(_rl(profile.role), style: TextStyle(color: c, fontSize: 11, fontWeight: FontWeight.bold)), + if (canChangeRole) ...[const SizedBox(width: 4), Icon(Icons.edit, size: 10, color: c.withOpacity(0.6))], + ]), + ), + ), + if (canDelete) ...[ + const SizedBox(height: 6), + GestureDetector( + onTap: onDelete, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: _red.withOpacity(0.1), borderRadius: BorderRadius.circular(8), + border: Border.all(color: _red.withOpacity(0.35))), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.person_remove, size: 11, color: _red), + SizedBox(width: 3), + Text('Remover', style: TextStyle(color: _red, fontSize: 10, fontWeight: FontWeight.bold)), + ]), + ), + ), + ], + ]), + ]), + ), + ); + } +} + +// ═════════════════════════════════════════════════════════════════════ +// TAB 2 — ACESSOS HOJE +// ═════════════════════════════════════════════════════════════════════ +class _AccessesTab extends ConsumerWidget { + const _AccessesTab(); + @override + Widget build(BuildContext context, WidgetRef ref) { + final sb = Supabase.instance.client; + final today = DateTime.now().toIso8601String().split('T')[0]; + return StreamBuilder>>( + stream: sb.from('daily_access_approvals').stream(primaryKey: ['id']) + .map((r) => r.where((x) => x['approval_date'] == today).toList()), + builder: (_, snap) { + if (!snap.hasData) return const _Loader(); + if (snap.data!.isEmpty) return _empty('Sem pedidos de acesso hoje', Icons.access_time_outlined); + final list = snap.data!.map(DailyAccessApproval.fromMap).toList() + ..sort((a,b) => a.status.compareTo(b.status)); + return ListView(padding: const EdgeInsets.all(16), + children: list.map((a) => _ApprovalTile(a: a, sb: sb)).toList()); + }, + ); + } +} + +class _ApprovalTile extends StatelessWidget { + final DailyAccessApproval a; final SupabaseClient sb; + const _ApprovalTile({required this.a, required this.sb}); + Color get _c => a.status == 'approved' ? _green : a.status == 'rejected' ? _red : _amber; + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), border: Border.all(color: _c.withOpacity(0.25))), + child: Column(children: [ + Row(children: [ + CircleAvatar(radius: 18, backgroundColor: _c.withOpacity(0.12), child: Icon(Icons.person_outline, color: _c, size: 18)), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('ID: ${a.userId.length > 12 ? a.userId.substring(0,12) : a.userId}...', + style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500)), + if (a.ipAddress != null) Text('IP: ${a.ipAddress}', style: const TextStyle(color: Color(0xFF888888), fontSize: 11)), + ])), + _Badge(text: a.status.toUpperCase(), color: _c), + ]), + if (a.status == 'pending') ...[ + const SizedBox(height: 12), + const Divider(color: Color(0xFF1E2233), height: 1), + const SizedBox(height: 12), + Row(children: [ + Expanded(child: _Btn(label: '✓ Aprovar', color: _green, onTap: () => sb.from('daily_access_approvals').update({'status':'approved','approved_at':DateTime.now().toIso8601String(),'approved_by':sb.auth.currentUser?.id}).eq('id',a.id))), + const SizedBox(width: 8), + Expanded(child: _Btn(label: '✕ Rejeitar', color: _red, onTap: () => sb.from('daily_access_approvals').update({'status':'rejected'}).eq('id',a.id))), + ]), + ], + ]), + ); +} + +// ═════════════════════════════════════════════════════════════════════ +// TAB 3 — LISTA CONVITES +// ═════════════════════════════════════════════════════════════════════ +class _InvitesTab extends ConsumerWidget { + final bool canManage; + const _InvitesTab({required this.canManage}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final sb = Supabase.instance.client; + return StreamBuilder>>( + stream: sb.from('invites').stream(primaryKey: ['id']), + builder: (_, snap) { + if (snap.hasError) return _errBox('Tabela "invites" não existe.\nCorre o COMPLETE_MIGRATION.sql.'); + if (!snap.hasData) return const _Loader(); + if (snap.data!.isEmpty) return _empty('Nenhum convite enviado ainda', Icons.mail_outline); + final list = snap.data!.map(Invite.fromMap).toList() + ..sort((a,b) => b.createdAt.compareTo(a.createdAt)); + return ListView(padding: const EdgeInsets.all(16), + children: list.map((i) => _InviteTile(inv: i, sb: sb, canManage: canManage)).toList()); + }, + ); + } +} + +class _InviteTile extends StatelessWidget { + final Invite inv; final SupabaseClient sb; final bool canManage; + const _InviteTile({required this.inv, required this.sb, required this.canManage}); + String get _sl { if (inv.isExpired && inv.status=='pending') return 'EXPIRADO'; switch(inv.status){case 'accepted':return 'ACEITE';case 'rejected':return 'RECUSADO';default:return 'PENDENTE';} } + Color get _c { if (inv.isExpired) return Colors.grey; switch(inv.status){case 'accepted':return _green;case 'rejected':return _red;default:return _amber;} } + String _fmt(DateTime d) => '${d.day.toString().padLeft(2,'0')}/${d.month.toString().padLeft(2,'0')} ${d.hour.toString().padLeft(2,'0')}:${d.minute.toString().padLeft(2,'0')}'; + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), border: Border.all(color: _c.withOpacity(0.2))), + child: Row(children: [ + CircleAvatar(radius: 20, backgroundColor: _rc(inv.role).withOpacity(0.12), + child: Text(inv.email[0].toUpperCase(), style: TextStyle(color: _rc(inv.role), fontWeight: FontWeight.bold))), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(inv.email, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), overflow: TextOverflow.ellipsis), + Text(_rl(inv.role), style: TextStyle(color: _rc(inv.role), fontSize: 11)), + Text('Exp: ${_fmt(inv.expiresAt)}', style: const TextStyle(color: Color(0xFF666688), fontSize: 10)), + ])), + const SizedBox(width: 8), + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + _Badge(text: _sl, color: _c), + if (canManage && inv.status == 'pending' && !inv.isExpired) ...[ + const SizedBox(height: 6), + GestureDetector(onTap: () => sb.from('invites').delete().eq('id', inv.id), + child: const Text('Cancelar', style: TextStyle(color: _red, fontSize: 11))), + ], + ]), + ]), + ); +} + +// ═════════════════════════════════════════════════════════════════════ +// TAB 4 — ENVIAR CONVITE +// ═════════════════════════════════════════════════════════════════════ +class _SendInviteTab extends ConsumerStatefulWidget { + final VoidCallback onSent; + const _SendInviteTab({required this.onSent}); + @override + ConsumerState<_SendInviteTab> createState() => _SendState(); +} + +class _SendState extends ConsumerState<_SendInviteTab> { + final _eCtrl = TextEditingController(); + final _pCtrl = TextEditingController(); + final _nCtrl = TextEditingController(); + String _role = 'teacher'; + bool _loading = false; + String? _err, _ok; + String? _childId; + List> _children = []; + + @override + void initState() { super.initState(); _fetchChildren(); } + @override + void dispose() { _eCtrl.dispose(); _pCtrl.dispose(); _nCtrl.dispose(); super.dispose(); } + + Future _fetchChildren() async { + try { + final d = await Supabase.instance.client.from('children').select('id,first_name,last_name').order('first_name'); + if (mounted) setState(() => _children = List>.from(d)); + } catch (_) {} + } + + Future _send() async { + final email = _eCtrl.text.trim(); + final name = _nCtrl.text.trim(); + if (email.isEmpty || name.isEmpty) { setState(() => _err = 'Preencha o nome e o email.'); return; } + if (!RegExp(r'^[\w\-\.]+@[\w\-\.]+\.\w{2,}$').hasMatch(email)) { setState(() => _err = 'Email inválido.'); return; } + if (_role == 'parent' && _childId == null) { setState(() => _err = 'Seleccione a criança.'); return; } + setState(() { _loading = true; _err = null; _ok = null; }); + try { + final sb = Supabase.instance.client; + final me = await sb.from('profiles').select('id').eq('user_id', sb.auth.currentUser!.id).single(); + final inviteId = const Uuid().v4(); + + // 1. Guardar na BD primeiro + await sb.from('invites').insert({ + 'id': inviteId, 'email': email, 'role': _role, + 'phone': _pCtrl.text.trim().isEmpty ? null : _pCtrl.text.trim(), + 'invited_by': me['id'], 'status': 'pending', + 'expires_at': DateTime.now().add(const Duration(days: 7)).toIso8601String(), + 'child_id': _childId, + }); + + // 2. Chamar Edge Function para enviar email + String emailStatus = ''; + try { + final accessToken = sb.auth.currentSession?.accessToken ?? ''; + final res = await sb.functions.invoke('send-invite', + headers: {'Authorization': 'Bearer $accessToken'}, + body: { + 'email': email, + 'role': _role, + 'name': name, + 'phone': _pCtrl.text.trim().isEmpty ? null : _pCtrl.text.trim(), + 'childId': _childId, + 'inviteId': inviteId, + }); + final data = res.data as Map?; + if (data?['userExists'] == true) { + emailStatus = '\nO utilizador já tem conta — o convite aparece ao fazer login.'; + } else { + emailStatus = '\n📧 Email enviado com link para criar conta!'; + } + } catch (emailErr) { + // Email falhou mas o convite está na BD — não é crítico + emailStatus = '\n⚠️ Email automático não disponível. Partilha o link da app manualmente.'; + } + + if (mounted) { + setState(() { + _ok = '✅ Convite criado para $email!$emailStatus'; + _eCtrl.clear(); _nCtrl.clear(); _pCtrl.clear(); _childId = null; + }); + widget.onSent(); + } + } catch (e) { + setState(() => _err = 'Erro: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient(colors: [_blue.withOpacity(0.1), Colors.transparent]), + borderRadius: BorderRadius.circular(16), border: Border.all(color: _blue.withOpacity(0.2)), + ), + child: const Row(children: [ + Icon(Icons.verified_user_outlined, color: _blue, size: 28), + SizedBox(width: 14), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Sistema de Convites', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)), + SizedBox(height: 3), + Text('A pessoa aceita o convite ao entrar na app. Válido 7 dias.', style: TextStyle(color: Color(0xFF888888), fontSize: 12)), + ])), + ]), + ), + const SizedBox(height: 20), + + if (_err != null) _fb(_err!, false), + if (_ok != null) _fb(_ok!, true), + + _lbl('Nome completo'), + _tf(_nCtrl, 'Ex: Maria da Silva', Icons.person_outline), + const SizedBox(height: 14), + _lbl('Email'), + _tf(_eCtrl, 'email@exemplo.com', Icons.alternate_email, TextInputType.emailAddress), + const SizedBox(height: 14), + _lbl('Telefone (opcional)'), + _tf(_pCtrl, '+244 9xx xxx xxx', Icons.phone_outlined, TextInputType.phone), + const SizedBox(height: 14), + _lbl('Função'), + ..._rolesMeta.map((r) { + final sel = _role == r['value']; + final c = _rc(r['value']!); + return GestureDetector( + onTap: () => setState(() { _role = r['value']!; _childId = null; }), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: sel ? c.withOpacity(0.1) : _card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: sel ? c : Colors.white.withOpacity(0.08), width: sel ? 1.5 : 1), + ), + child: Row(children: [ + Text(r['label']!, style: TextStyle(color: sel ? c : Colors.white, fontWeight: FontWeight.w600, fontSize: 14)), + const SizedBox(width: 6), + Expanded(child: Text(r['desc']!, style: const TextStyle(color: Color(0xFF666688), fontSize: 11))), + AnimatedContainer(duration: const Duration(milliseconds: 120), + width: 20, height: 20, + decoration: BoxDecoration(shape: BoxShape.circle, color: sel ? c : Colors.transparent, border: Border.all(color: sel ? c : Colors.white24, width: 2)), + child: sel ? const Icon(Icons.check, size: 12, color: Colors.white) : null), + ]), + ), + ); + }), + + if (_role == 'parent') ...[ + const SizedBox(height: 14), + _lbl('Criança associada *'), + Container( + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white.withOpacity(0.1))), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _childId, isExpanded: true, dropdownColor: _card, + style: const TextStyle(color: Colors.white, fontSize: 14), + padding: const EdgeInsets.symmetric(horizontal: 14), + hint: const Text('Seleccione a criança', style: TextStyle(color: Color(0xFF888888))), + items: _children.map((c) => DropdownMenuItem(value: c['id'] as String, + child: Text('${c['first_name']} ${c['last_name']}'))).toList(), + onChanged: (v) => setState(() => _childId = v), + ), + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: _blue.withOpacity(0.06), borderRadius: BorderRadius.circular(10), border: Border.all(color: _blue.withOpacity(0.15))), + child: const Row(children: [ + Icon(Icons.info_outline, color: _blue, size: 14), + SizedBox(width: 8), + Expanded(child: Text('O encarregado só acede ao diário, presenças e mensagens do seu filho.', style: TextStyle(color: Color(0xFF888888), fontSize: 11))), + ]), + ), + ], + + const SizedBox(height: 24), + GestureDetector( + onTap: _loading ? null : _send, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 54, width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient(colors: _loading ? [const Color(0xFF1A3A4A), const Color(0xFF1A3A4A)] : [_blue, const Color(0xFF0288D1)]), + borderRadius: BorderRadius.circular(14), + boxShadow: _loading ? [] : [BoxShadow(color: _blue.withOpacity(0.3), blurRadius: 20, offset: const Offset(0,6))], + ), + child: Center(child: _loading + ? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) + : const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.send_outlined, color: Colors.white, size: 18), + SizedBox(width: 8), + Text('Enviar Convite', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)), + ])), + ), + ), + const SizedBox(height: 24), + // Como funciona + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.white.withOpacity(0.05))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Row(children: [Icon(Icons.help_outline, color: _blue, size: 15), SizedBox(width: 8), Text('Como funciona?', style: TextStyle(color: _blue, fontWeight: FontWeight.bold, fontSize: 13))]), + const SizedBox(height: 12), + ...[(_blue,'1','Envias o convite (email + função)'), (_amber,'2','A pessoa instala a app e cria conta'), (_green,'3','Ao entrar, vê automaticamente o convite'), (_blue,'4','Aceita → fica com o role atribuído'), (_green,'5','Acede às suas funcionalidades')].map((s) => Padding( + padding: const EdgeInsets.only(bottom: 7), + child: Row(children: [ + Container(width: 22, height: 22, decoration: BoxDecoration(color: s.$1.withOpacity(0.15), shape: BoxShape.circle), child: Center(child: Text(s.$2, style: TextStyle(color: s.$1, fontSize: 11, fontWeight: FontWeight.bold)))), + const SizedBox(width: 10), + Expanded(child: Text(s.$3, style: const TextStyle(color: Color(0xFFAAAAAA), fontSize: 12))), + ]), + )), + ]), + ), + const SizedBox(height: 40), + ]), + ); + } + + Widget _lbl(String t) => Padding(padding: const EdgeInsets.only(bottom: 7), + child: Text(t, style: const TextStyle(color: Color(0xFFAAAAAA), fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.5))); + + Widget _tf(TextEditingController c, String hint, IconData icon, [TextInputType? t]) => TextField( + controller: c, keyboardType: t, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + hintText: hint, hintStyle: const TextStyle(color: Color(0xFF555577), fontSize: 13), + prefixIcon: Icon(icon, color: _blue.withOpacity(0.7), size: 20), + filled: true, fillColor: _card, + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.white.withOpacity(0.1))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _blue, width: 1.5)), + contentPadding: const EdgeInsets.symmetric(vertical: 14), + ), + ); + + Widget _fb(String msg, bool ok) => Container( + margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: (ok ? _green : _red).withOpacity(0.1), borderRadius: BorderRadius.circular(12), + border: Border.all(color: (ok ? _green : _red).withOpacity(0.4))), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(ok ? Icons.check_circle_outline : Icons.error_outline, color: ok ? _green : _red, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(msg, style: TextStyle(color: ok ? _green : _red, fontSize: 12))), + GestureDetector(onTap: () => setState(() => ok ? _ok = null : _err = null), + child: const Icon(Icons.close, color: Colors.white30, size: 16)), + ]), + ); +} + +// ── Widgets comuns ───────────────────────────────────────────────────── +class _Badge extends StatelessWidget { + final String text; final Color color; + const _Badge({required this.text, required this.color}); + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration(color: color.withOpacity(0.12), borderRadius: BorderRadius.circular(20)), + child: Text(text, style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.bold)), + ); +} + +class _Btn extends StatelessWidget { + final String label; final Color color; final VoidCallback onTap; + const _Btn({required this.label, required this.color, required this.onTap}); + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(10), border: Border.all(color: color.withOpacity(0.4))), + child: Center(child: Text(label, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13))), + ), + ); +} + +class _Loader extends StatelessWidget { + const _Loader(); + @override + Widget build(BuildContext context) => const Center(child: CircularProgressIndicator(color: _blue)); +} + +Widget _empty(String msg, IconData icon) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 64, color: Colors.white.withOpacity(0.07)), const SizedBox(height: 12), + Text(msg, style: const TextStyle(color: Color(0xFF888888))), +])); + +Widget _errBox(String msg) => Center(child: Container( + margin: const EdgeInsets.all(24), padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: _red.withOpacity(0.07), borderRadius: BorderRadius.circular(14), border: Border.all(color: _red.withOpacity(0.3))), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.error_outline, color: _red, size: 32), const SizedBox(height: 10), + Text(msg, style: const TextStyle(color: _red, fontSize: 13), textAlign: TextAlign.center), + const SizedBox(height: 8), + const Text('Corre o COMPLETE_MIGRATION.sql no Supabase SQL Editor.', style: TextStyle(color: Color(0xFF888888), fontSize: 11), textAlign: TextAlign.center), + ]), +)); + +void _snack(BuildContext ctx, String msg, {bool ok = false}) => + ScaffoldMessenger.of(ctx).showSnackBar(SnackBar( + content: Text(msg, style: const TextStyle(color: Colors.white)), + backgroundColor: ok ? _green : _red, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + )); diff --git a/creche_app/lib/main.dart b/creche_app/lib/main.dart new file mode 100644 index 0000000..98f6920 --- /dev/null +++ b/creche_app/lib/main.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:app_links/app_links.dart'; +import 'core/routes.dart'; + +const _supabaseUrl = 'https://xeotegswjwmhkwvtuxgx.supabase.co'; +const _supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inhlb3RlZ3N3andtaGt3dnR1eGd4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIxODEwMjcsImV4cCI6MjA4Nzc1NzAyN30.PW6IQhpO8PRhPzA3ycPOgy_-Pqw9XQ0BCCE5ukPCcVM'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await initializeDateFormatting('pt_PT', null); + + await Supabase.initialize( + url: _supabaseUrl, + anonKey: _supabaseAnonKey, + authOptions: const FlutterAuthClientOptions( + authFlowType: AuthFlowType.pkce, + ), + ); + + runApp(const ProviderScope(child: CrecheApp())); +} + +class CrecheApp extends ConsumerStatefulWidget { + const CrecheApp({super.key}); + @override + ConsumerState createState() => _CrecheAppState(); +} + +class _CrecheAppState extends ConsumerState { + final _appLinks = AppLinks(); + + @override + void initState() { + super.initState(); + _initDeepLinks(); + } + + Future _initDeepLinks() async { + try { + final initialUri = await _appLinks.getInitialLink(); + if (initialUri != null && _isAuthUri(initialUri)) { + await _handleDeepLink(initialUri); + } + } catch (_) {} + + _appLinks.uriLinkStream.listen( + (uri) { if (_isAuthUri(uri)) _handleDeepLink(uri); }, + onError: (_) {}, + ); + } + + bool _isAuthUri(Uri uri) { + final q = uri.queryParameters; + return q.containsKey('code') || + q.containsKey('access_token') || + q.containsKey('token_hash') || + uri.host == 'login-callback' || + uri.path.contains('login-callback'); + } + + Future _handleDeepLink(Uri uri) async { + try { + await Supabase.instance.client.auth.getSessionFromUrl(uri); + } catch (e) { + debugPrint('Deep link error: $e'); + } + } + + @override + Widget build(BuildContext context) { + final router = ref.watch(goRouterProvider); + return MaterialApp.router( + title: 'Diário do Candengue', + debugShowCheckedModeBanner: false, + routerConfig: router, + theme: ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: const Color(0xFF0D1117), + colorScheme: const ColorScheme.dark( + primary: Color(0xFF4FC3F7), + secondary: Color(0xFF2ECC71), + surface: Color(0xFF161B22), + error: Color(0xFFE74C3C), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFF161B22), + elevation: 0, + iconTheme: IconThemeData(color: Color(0xFF4FC3F7)), + titleTextStyle: TextStyle( + color: Color(0xFF4FC3F7), fontSize: 18, fontWeight: FontWeight.bold), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: Color(0xFF161B22), + selectedItemColor: Color(0xFF4FC3F7), + unselectedItemColor: Color(0xFF888888), + type: BottomNavigationBarType.fixed, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white.withOpacity(0.04), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withOpacity(0.09)), + ), + ), + useMaterial3: true, + ), + ); + } +} diff --git a/creche_app/lib/models/announcement.dart b/creche_app/lib/models/announcement.dart new file mode 100644 index 0000000..bc1f1c3 --- /dev/null +++ b/creche_app/lib/models/announcement.dart @@ -0,0 +1,33 @@ +class Announcement { + final String id; + final String title; + final String content; + final DateTime createdAt; + final String? targetRole; + + Announcement({ + required this.id, + required this.title, + required this.content, + required this.createdAt, + this.targetRole, + }); + + factory Announcement.fromMap(Map map) { + return Announcement( + id: map['id'] ?? '', + title: map['title'] ?? '', + content: map['content'] ?? '', + createdAt: DateTime.tryParse(map['created_at'] ?? '') ?? DateTime.now(), + targetRole: map['target_role'], + ); + } + + Map toMap() { + return { + 'title': title, + 'content': content, + 'target_role': targetRole, + }; + } +} diff --git a/creche_app/lib/models/attendance.dart b/creche_app/lib/models/attendance.dart new file mode 100644 index 0000000..d8fe7b4 --- /dev/null +++ b/creche_app/lib/models/attendance.dart @@ -0,0 +1,42 @@ +class Attendance { + final String id; + final String childId; + final DateTime date; + final String? status; // present | absent | late + final String? timeIn; + final String? timeOut; + final String? notes; + + Attendance({ + required this.id, + required this.childId, + required this.date, + this.status, + this.timeIn, + this.timeOut, + this.notes, + }); + + factory Attendance.fromMap(Map map) { + return Attendance( + id: map['id'] ?? '', + childId: map['child_id'] ?? '', + date: DateTime.tryParse(map['date'] ?? '') ?? DateTime.now(), + status: map['status'], + timeIn: map['time_in'], + timeOut: map['time_out'], + notes: map['notes'], + ); + } + + Map toMap() { + return { + 'child_id': childId, + 'date': date.toIso8601String().split('T')[0], + 'status': status, + 'time_in': timeIn, + 'time_out': timeOut, + 'notes': notes, + }; + } +} diff --git a/creche_app/lib/models/child.dart b/creche_app/lib/models/child.dart new file mode 100644 index 0000000..3503bc8 --- /dev/null +++ b/creche_app/lib/models/child.dart @@ -0,0 +1,70 @@ +import 'package:uuid/uuid.dart'; + +class Child { + final String id; + final String firstName; + final String lastName; + final DateTime birthDate; + final String? photoUrl; + final String classId; + final String teacherId; + final String status; + final String? mood; + final String? allergies; // ← NOVO + final String? foodRestrictions; // ← NOVO + final String? roomId; // ← NOVO + + Child({ + String? id, + required this.firstName, + required this.lastName, + required this.birthDate, + this.photoUrl, + required this.classId, + required this.teacherId, + this.status = 'active', + this.mood, + this.allergies, + this.foodRestrictions, + this.roomId, + }) : id = id ?? const Uuid().v4(); + + int get age { + final today = DateTime.now(); + int a = today.year - birthDate.year; + if (today.month < birthDate.month || + (today.month == birthDate.month && today.day < birthDate.day)) a--; + return a; + } + + String get fullName => '$firstName $lastName'; + + factory Child.fromMap(Map map) => Child( + id: map['id'], + firstName: map['first_name'] ?? '', + lastName: map['last_name'] ?? '', + birthDate: DateTime.tryParse(map['birth_date'] ?? '') ?? DateTime.now(), + photoUrl: map['photo_url'], + classId: map['class_id'] ?? '', + teacherId: map['teacher_id'] ?? '', + status: map['status'] ?? 'active', + mood: map['mood'], + allergies: map['allergies'], + foodRestrictions: map['food_restrictions'], + roomId: map['room_id'], + ); + + Map toMap() => { + 'id': id, + 'first_name': firstName, + 'last_name': lastName, + 'birth_date': birthDate.toIso8601String().split('T')[0], + 'photo_url': photoUrl, + 'class_id': classId, + 'teacher_id': teacherId, + 'status': status, + 'allergies': allergies, + 'food_restrictions': foodRestrictions, + 'room_id': roomId, + }; +} diff --git a/creche_app/lib/models/class_model.dart b/creche_app/lib/models/class_model.dart new file mode 100644 index 0000000..8314411 --- /dev/null +++ b/creche_app/lib/models/class_model.dart @@ -0,0 +1,30 @@ +class ClassModel { + final String id; + final String name; + final int capacity; + final String? teacherId; + + ClassModel({ + required this.id, + required this.name, + required this.capacity, + this.teacherId, + }); + + factory ClassModel.fromMap(Map map) { + return ClassModel( + id: map['id'] ?? '', + name: map['name'] ?? '', + capacity: map['capacity'] ?? 15, + teacherId: map['teacher_id'], + ); + } + + Map toMap() { + return { + 'name': name, + 'capacity': capacity, + 'teacher_id': teacherId, + }; + } +} diff --git a/creche_app/lib/models/creche_settings.dart b/creche_app/lib/models/creche_settings.dart new file mode 100644 index 0000000..29c2495 --- /dev/null +++ b/creche_app/lib/models/creche_settings.dart @@ -0,0 +1,50 @@ +class CrecheSettings { + final int id; + final String name; + final String? logoUrl; + final String? address; + final String slogan; + final List allowedIps; + final double? geofenceLat; + final double? geofenceLng; + final int geofenceRadiusMeters; + + CrecheSettings({ + required this.id, + required this.name, + this.logoUrl, + this.address, + required this.slogan, + required this.allowedIps, + this.geofenceLat, + this.geofenceLng, + required this.geofenceRadiusMeters, + }); + + factory CrecheSettings.fromMap(Map map) { + return CrecheSettings( + id: map['id'] ?? 1, + name: map['name'] ?? 'Creche e Berçário Sementes do Futuro', + logoUrl: map['logo_url'], + address: map['address'], + slogan: map['slogan'] ?? 'Conforto, cuidado e aprendizagem', + allowedIps: List.from(map['allowed_ips'] ?? []), + geofenceLat: map['geofence_lat']?.toDouble(), + geofenceLng: map['geofence_lng']?.toDouble(), + geofenceRadiusMeters: map['geofence_radius_meters'] ?? 150, + ); + } + + Map toMap() { + return { + 'name': name, + 'logo_url': logoUrl, + 'address': address, + 'slogan': slogan, + 'allowed_ips': allowedIps, + 'geofence_lat': geofenceLat, + 'geofence_lng': geofenceLng, + 'geofence_radius_meters': geofenceRadiusMeters, + }; + } +} diff --git a/creche_app/lib/models/daily_access_approval.dart b/creche_app/lib/models/daily_access_approval.dart new file mode 100644 index 0000000..9e8a17c --- /dev/null +++ b/creche_app/lib/models/daily_access_approval.dart @@ -0,0 +1,53 @@ +class DailyAccessApproval { + final String id; + final String userId; + final DateTime approvalDate; + final String status; // pending | approved | rejected + final String? approvedBy; + final DateTime? approvedAt; + final String? ipAddress; + final double? locationLat; + final double? locationLng; + + DailyAccessApproval({ + required this.id, + required this.userId, + required this.approvalDate, + required this.status, + this.approvedBy, + this.approvedAt, + this.ipAddress, + this.locationLat, + this.locationLng, + }); + + factory DailyAccessApproval.fromMap(Map map) { + return DailyAccessApproval( + id: map['id'] ?? '', + userId: map['user_id'] ?? '', + approvalDate: DateTime.tryParse(map['approval_date'] ?? '') ?? DateTime.now(), + status: map['status'] ?? 'pending', + approvedBy: map['approved_by'], + approvedAt: map['approved_at'] != null + ? DateTime.tryParse(map['approved_at']) + : null, + ipAddress: map['ip_address'], + locationLat: map['location_lat']?.toDouble(), + locationLng: map['location_lng']?.toDouble(), + ); + } + + Map toMap() { + return { + 'id': id, + 'user_id': userId, + 'approval_date': approvalDate.toIso8601String().split('T')[0], + 'status': status, + 'approved_by': approvedBy, + 'approved_at': approvedAt?.toIso8601String(), + 'ip_address': ipAddress, + 'location_lat': locationLat, + 'location_lng': locationLng, + }; + } +} diff --git a/creche_app/lib/models/daily_diary.dart b/creche_app/lib/models/daily_diary.dart new file mode 100644 index 0000000..c3f42cf --- /dev/null +++ b/creche_app/lib/models/daily_diary.dart @@ -0,0 +1,57 @@ +class DailyDiary { + final String id; + final String childId; + final DateTime date; + final String teacherId; + final String? food; + final int? sleepMinutes; + final String? activities; + final String? mood; + final String? notes; + final List photos; + final DateTime createdAt; + + DailyDiary({ + required this.id, + required this.childId, + required this.date, + required this.teacherId, + this.food, + this.sleepMinutes, + this.activities, + this.mood, + this.notes, + required this.photos, + required this.createdAt, + }); + + factory DailyDiary.fromMap(Map map) { + return DailyDiary( + id: map['id'] ?? '', + childId: map['child_id'] ?? '', + date: DateTime.tryParse(map['date'] ?? '') ?? DateTime.now(), + teacherId: map['teacher_id'] ?? '', + food: map['food'], + sleepMinutes: map['sleep_minutes'], + activities: map['activities'], + mood: map['mood'], + notes: map['notes'], + photos: List.from(map['photos'] ?? []), + createdAt: DateTime.tryParse(map['created_at'] ?? '') ?? DateTime.now(), + ); + } + + Map toMap() { + return { + 'child_id': childId, + 'date': date.toIso8601String().split('T')[0], + 'teacher_id': teacherId, + 'food': food, + 'sleep_minutes': sleepMinutes, + 'activities': activities, + 'mood': mood, + 'notes': notes, + 'photos': photos, + }; + } +} diff --git a/creche_app/lib/models/invite.dart b/creche_app/lib/models/invite.dart new file mode 100644 index 0000000..b5887cc --- /dev/null +++ b/creche_app/lib/models/invite.dart @@ -0,0 +1,49 @@ +/// Representa um convite pendente no sistema +class Invite { + final String id; + final String email; + final String role; + final String? phone; // para encarregados — número do terminal + final String invitedBy; // profile_id do principal que convidou + final String status; // pending | accepted | rejected | expired + final DateTime createdAt; + final DateTime expiresAt; + final String? childId; // para encarregados — ligação à criança + + Invite({ + required this.id, + required this.email, + required this.role, + this.phone, + required this.invitedBy, + required this.status, + required this.createdAt, + required this.expiresAt, + this.childId, + }); + + factory Invite.fromMap(Map m) => Invite( + id: m['id'] ?? '', + email: m['email'] ?? '', + role: m['role'] ?? 'parent', + phone: m['phone'], + invitedBy: m['invited_by'] ?? '', + status: m['status'] ?? 'pending', + createdAt: DateTime.tryParse(m['created_at'] ?? '') ?? DateTime.now(), + expiresAt: DateTime.tryParse(m['expires_at'] ?? '') ?? DateTime.now().add(const Duration(days: 7)), + childId: m['child_id'], + ); + + bool get isExpired => DateTime.now().isAfter(expiresAt); + bool get isPending => status == 'pending' && !isExpired; + + Map toMap() => { + 'email': email, + 'role': role, + 'phone': phone, + 'invited_by': invitedBy, + 'status': status, + 'expires_at': expiresAt.toIso8601String(), + 'child_id': childId, + }; +} diff --git a/creche_app/lib/models/message.dart b/creche_app/lib/models/message.dart new file mode 100644 index 0000000..764f0f8 --- /dev/null +++ b/creche_app/lib/models/message.dart @@ -0,0 +1,37 @@ +class Message { + final String id; + final String fromUser; + final String toUser; + final String content; + final bool isRead; + final DateTime createdAt; + + Message({ + required this.id, + required this.fromUser, + required this.toUser, + required this.content, + required this.isRead, + required this.createdAt, + }); + + factory Message.fromMap(Map map) { + return Message( + id: map['id'] ?? '', + fromUser: map['from_user'] ?? '', + toUser: map['to_user'] ?? '', + content: map['content'] ?? '', + isRead: map['is_read'] ?? false, + createdAt: DateTime.tryParse(map['created_at'] ?? '') ?? DateTime.now(), + ); + } + + Map toMap() { + return { + 'from_user': fromUser, + 'to_user': toUser, + 'content': content, + 'is_read': isRead, + }; + } +} diff --git a/creche_app/lib/models/payment.dart b/creche_app/lib/models/payment.dart new file mode 100644 index 0000000..b7409de --- /dev/null +++ b/creche_app/lib/models/payment.dart @@ -0,0 +1,42 @@ +class Payment { + final String id; + final String childId; // ← adicionado + final String guardianId; + final DateTime month; + final double amount; + final String status; // pending | paid | overdue + final DateTime? paidAt; + final String? receiptUrl; + + Payment({ + required this.id, + required this.childId, + required this.guardianId, + required this.month, + required this.amount, + required this.status, + this.paidAt, + this.receiptUrl, + }); + + factory Payment.fromMap(Map map) => Payment( + id: map['id'] ?? '', + childId: map['child_id'] ?? '', + guardianId: map['guardian_id'] ?? '', + month: DateTime.tryParse(map['month'] ?? '') ?? DateTime.now(), + amount: (map['amount'] ?? 0).toDouble(), + status: map['status'] ?? 'pending', + paidAt: map['paid_at'] != null ? DateTime.tryParse(map['paid_at']) : null, + receiptUrl: map['receipt_url'], + ); + + Map toMap() => { + 'child_id': childId, + 'guardian_id': guardianId, + 'month': month.toIso8601String().split('T')[0], + 'amount': amount, + 'status': status, + 'paid_at': paidAt?.toIso8601String(), + 'receipt_url': receiptUrl, + }; +} diff --git a/creche_app/lib/models/profile.dart b/creche_app/lib/models/profile.dart new file mode 100644 index 0000000..d8c11c4 --- /dev/null +++ b/creche_app/lib/models/profile.dart @@ -0,0 +1,46 @@ +class Profile { + final String id; + final String userId; + final String fullName; + final String? avatarUrl; + final String? phone; + final String role; + final String? position; + final DateTime createdAt; + + Profile({ + required this.id, + required this.userId, + required this.fullName, + this.avatarUrl, + this.phone, + required this.role, + this.position, + required this.createdAt, + }); + + factory Profile.fromMap(Map map) { + return Profile( + id: map['id'] ?? '', + userId: map['user_id'] ?? '', + fullName: map['full_name'] ?? '', + avatarUrl: map['avatar_url'], + phone: map['phone'], + role: map['role'] ?? 'parent', + position: map['position'], + createdAt: DateTime.tryParse(map['created_at'] ?? '') ?? DateTime.now(), + ); + } + + Map toMap() { + return { + 'id': id, + 'user_id': userId, + 'full_name': fullName, + 'avatar_url': avatarUrl, + 'phone': phone, + 'role': role, + 'position': position, + }; + } +} diff --git a/creche_app/lib/service/invite_service.dart b/creche_app/lib/service/invite_service.dart new file mode 100644 index 0000000..f78a973 --- /dev/null +++ b/creche_app/lib/service/invite_service.dart @@ -0,0 +1,28 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +class InviteService { + +final supabase = Supabase.instance.client; + +Future cancelInvite(String inviteId) async { + +await supabase +.from('invites') +.update({ +'status': 'cancelled', +'cancelled_at': DateTime.now().toIso8601String() +}) +.eq('id', inviteId); + +} + +Future deleteInvite(String inviteId) async { + +await supabase +.from('invites') +.delete() +.eq('id', inviteId); + +} + +} diff --git a/creche_app/lib/shared/themes.dart b/creche_app/lib/shared/themes.dart new file mode 100644 index 0000000..1332b29 --- /dev/null +++ b/creche_app/lib/shared/themes.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +const kPrimaryBlue = Color(0xFF4FC3F7); +const kAccentGreen = Color(0xFFA5D6A7); +const kDarkBlue = Color(0xFF1976D2); +const kBackground = Color(0xFFF5F5F5); +const kCardBackground = Colors.white; + +final appTheme = ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: kPrimaryBlue, + primary: kPrimaryBlue, + secondary: kAccentGreen, + background: kBackground, + ), + scaffoldBackgroundColor: kBackground, + appBarTheme: const AppBarTheme( + backgroundColor: kPrimaryBlue, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + titleTextStyle: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: kPrimaryBlue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: kPrimaryBlue, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + cardTheme: CardThemeData( + elevation: 3, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + color: kCardBackground, + ), + textTheme: const TextTheme( + headlineMedium: TextStyle( + color: kDarkBlue, fontWeight: FontWeight.bold, fontSize: 24), + titleLarge: + TextStyle(color: kDarkBlue, fontWeight: FontWeight.w600, fontSize: 18), + bodyLarge: TextStyle(color: Color(0xFF333333), fontSize: 16), + bodyMedium: TextStyle(color: Color(0xFF555555), fontSize: 14), + ), +); diff --git a/creche_app/lib/shared/widgets/custom_button.dart b/creche_app/lib/shared/widgets/custom_button.dart new file mode 100644 index 0000000..754017a --- /dev/null +++ b/creche_app/lib/shared/widgets/custom_button.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +class CustomButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final Color? backgroundColor; + final Color? textColor; + final IconData? icon; + final bool isLoading; + + const CustomButton({ + super.key, + required this.text, + this.onPressed, + this.backgroundColor, + this.textColor, + this.icon, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary, + foregroundColor: textColor ?? Colors.white, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + ), + child: isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2.5), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 20), + const SizedBox(width: 8), + ], + Text(text, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.bold)), + ], + ), + ); + } +} diff --git a/creche_app/linux/.gitignore b/creche_app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/creche_app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/creche_app/linux/CMakeLists.txt b/creche_app/linux/CMakeLists.txt new file mode 100644 index 0000000..dcbb35d --- /dev/null +++ b/creche_app/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 "creche_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "Hilaritech.com.creche_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/creche_app/linux/flutter/CMakeLists.txt b/creche_app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/creche_app/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/creche_app/linux/flutter/generated_plugin_registrant.cc b/creche_app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..ea3bde6 --- /dev/null +++ b/creche_app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,27 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/creche_app/linux/flutter/generated_plugin_registrant.h b/creche_app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/creche_app/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/creche_app/linux/flutter/generated_plugins.cmake b/creche_app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..0420466 --- /dev/null +++ b/creche_app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + flutter_secure_storage_linux + gtk + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/creche_app/linux/runner/CMakeLists.txt b/creche_app/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/creche_app/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/creche_app/linux/runner/main.cc b/creche_app/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/creche_app/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/creche_app/linux/runner/my_application.cc b/creche_app/linux/runner/my_application.cc new file mode 100644 index 0000000..6c5b95a --- /dev/null +++ b/creche_app/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "creche_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, "creche_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/creche_app/linux/runner/my_application.h b/creche_app/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/creche_app/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/creche_app/macos/.gitignore b/creche_app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/creche_app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/creche_app/macos/Flutter/Flutter-Debug.xcconfig b/creche_app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/creche_app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/creche_app/macos/Flutter/Flutter-Release.xcconfig b/creche_app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/creche_app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/creche_app/macos/Flutter/GeneratedPluginRegistrant.swift b/creche_app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..4937af5 --- /dev/null +++ b/creche_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import file_selector_macos +import flutter_secure_storage_macos +import geolocator_apple +import local_auth_darwin +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/creche_app/macos/Runner.xcodeproj/project.pbxproj b/creche_app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..ba56334 --- /dev/null +++ b/creche_app/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 /* creche_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "creche_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 /* creche_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 /* creche_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 = Hilari-tech.com.crecheApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/creche_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/creche_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 = Hilari-tech.com.crecheApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/creche_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/creche_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 = Hilari-tech.com.crecheApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/creche_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/creche_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.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/creche_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/creche_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/creche_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/creche_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/creche_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..0949a85 --- /dev/null +++ b/creche_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/creche_app/macos/Runner.xcworkspace/contents.xcworkspacedata b/creche_app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/creche_app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/creche_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/creche_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/creche_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/creche_app/macos/Runner/AppDelegate.swift b/creche_app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/creche_app/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/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/creche_app/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/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/creche_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/creche_app/macos/Runner/Base.lproj/MainMenu.xib b/creche_app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/creche_app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/creche_app/macos/Runner/Configs/AppInfo.xcconfig b/creche_app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..3bc59a5 --- /dev/null +++ b/creche_app/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 = creche_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = Hilari-tech.com.crecheApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 Hilari-tech.com. All rights reserved. diff --git a/creche_app/macos/Runner/Configs/Debug.xcconfig b/creche_app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/creche_app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/creche_app/macos/Runner/Configs/Release.xcconfig b/creche_app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/creche_app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/creche_app/macos/Runner/Configs/Warnings.xcconfig b/creche_app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/creche_app/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/creche_app/macos/Runner/DebugProfile.entitlements b/creche_app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/creche_app/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/creche_app/macos/Runner/Info.plist b/creche_app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/creche_app/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/creche_app/macos/Runner/MainFlutterWindow.swift b/creche_app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/creche_app/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/creche_app/macos/Runner/Release.entitlements b/creche_app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/creche_app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/creche_app/macos/RunnerTests/RunnerTests.swift b/creche_app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/creche_app/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/creche_app/pubspec.lock b/creche_app/pubspec.lock new file mode 100644 index 0000000..ae975b5 --- /dev/null +++ b/creche_app/pubspec.lock @@ -0,0 +1,1471 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" + app_links: + dependency: transitive + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + 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" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + url: "https://pub.dev" + source: hosted + version: "8.12.4" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: c6ecb3bb991c459b91c5adf9e871113dcb32bbe8fe7ca2c92723f88ffc1e0b7a + url: "https://pub.dev" + source: hosted + version: "3.3.2" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "5276944c6ffc975ae796569a826c38a62d2abcf264e26b88fa6f482e107f4237" + url: "https://pub.dev" + source: hosted + version: "0.70.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "149876cc5207a0f5daf4fdd3bfcf0a0f27258b3fe95108fa084f527ad0568f1b" + url: "https://pub.dev" + source: hosted + version: "12.0.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: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: f7b52008311941a7c3e99f9590c4ee32dfc102a5442e43abf1b287d9f8cc39b2 + url: "https://pub.dev" + source: hosted + version: "2.18.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + url: "https://pub.dev" + source: hosted + version: "1.0.56" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: "8ae0be46dbd9e19641791dc12ee480d34e1fd3f84c749adc05f3ad9342b71b95" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + 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: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + 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: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: 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" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "5268afc208d02fb9109854d262c1ebf6ece224cd285199ae1d2f92d2ff49dbf1" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611" + url: "https://pub.dev" + source: hosted + version: "0.5.10" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36" + url: "https://pub.dev" + source: hosted + version: "2.6.5" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "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" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + 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" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: cc039f63a3168386b3a4f338f3bff342c860d415a3578f3fbe854024aee6f911 + url: "https://pub.dev" + source: hosted + version: "2.10.2" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "92b2416ecb6a5c3ed34cf6e382b35ce6cc8921b64f2a9299d5d28968d42b09bb" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.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: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/creche_app/pubspec.yaml b/creche_app/pubspec.yaml new file mode 100644 index 0000000..1807d81 --- /dev/null +++ b/creche_app/pubspec.yaml @@ -0,0 +1,44 @@ +name: creche_app +description: "Creche e Berçário Sementes do Futuro 'Conforto, Cuidado e Aprendizagem'" +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.5.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + cupertino_icons: ^1.0.8 + supabase_flutter: ^2.8.0 + flutter_riverpod: ^2.5.1 + riverpod_annotation: ^2.6.1 + go_router: ^14.0.0 + flutter_secure_storage: ^9.2.2 + local_auth: ^2.2.0 + image_picker: ^1.1.2 + cached_network_image: ^3.4.1 + fl_chart: ^0.70.0 + table_calendar: ^3.1.2 + uuid: ^4.5.0 + intl: ^0.20.2 + lottie: ^3.1.2 + geolocator: ^12.0.0 + http: ^1.2.2 + app_links: ^6.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + build_runner: ^2.4.12 + riverpod_generator: ^2.5.1 + +flutter: + uses-material-design: true + assets: + - assets/logo.png + - assets/splash_animation.json + - assets/waiting.json diff --git a/creche_app/web/favicon.png b/creche_app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/creche_app/web/favicon.png differ diff --git a/creche_app/web/icons/Icon-192.png b/creche_app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/creche_app/web/icons/Icon-192.png differ diff --git a/creche_app/web/icons/Icon-512.png b/creche_app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/creche_app/web/icons/Icon-512.png differ diff --git a/creche_app/web/icons/Icon-maskable-192.png b/creche_app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/creche_app/web/icons/Icon-maskable-192.png differ diff --git a/creche_app/web/icons/Icon-maskable-512.png b/creche_app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/creche_app/web/icons/Icon-maskable-512.png differ diff --git a/creche_app/web/index.html b/creche_app/web/index.html new file mode 100644 index 0000000..e78d9e4 --- /dev/null +++ b/creche_app/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + creche_app + + + + + + diff --git a/creche_app/web/manifest.json b/creche_app/web/manifest.json new file mode 100644 index 0000000..8e4bbb0 --- /dev/null +++ b/creche_app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "creche_app", + "short_name": "creche_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/creche_app/windows/.gitignore b/creche_app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/creche_app/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/creche_app/windows/CMakeLists.txt b/creche_app/windows/CMakeLists.txt new file mode 100644 index 0000000..c8abfa3 --- /dev/null +++ b/creche_app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(creche_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 "creche_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/creche_app/windows/flutter/CMakeLists.txt b/creche_app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/creche_app/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/creche_app/windows/flutter/generated_plugin_registrant.cc b/creche_app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..1bbad53 --- /dev/null +++ b/creche_app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,29 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/creche_app/windows/flutter/generated_plugin_registrant.h b/creche_app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/creche_app/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/creche_app/windows/flutter/generated_plugins.cmake b/creche_app/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..6fb39cf --- /dev/null +++ b/creche_app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + app_links + file_selector_windows + flutter_secure_storage_windows + geolocator_windows + local_auth_windows + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/creche_app/windows/runner/CMakeLists.txt b/creche_app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/creche_app/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/creche_app/windows/runner/Runner.rc b/creche_app/windows/runner/Runner.rc new file mode 100644 index 0000000..ac34672 --- /dev/null +++ b/creche_app/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", "Hilari-tech.com" "\0" + VALUE "FileDescription", "creche_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "creche_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 Hilari-tech.com. All rights reserved." "\0" + VALUE "OriginalFilename", "creche_app.exe" "\0" + VALUE "ProductName", "creche_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/creche_app/windows/runner/flutter_window.cpp b/creche_app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/creche_app/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/creche_app/windows/runner/flutter_window.h b/creche_app/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/creche_app/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/creche_app/windows/runner/main.cpp b/creche_app/windows/runner/main.cpp new file mode 100644 index 0000000..662cd77 --- /dev/null +++ b/creche_app/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"creche_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/creche_app/windows/runner/resource.h b/creche_app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/creche_app/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/creche_app/windows/runner/resources/app_icon.ico b/creche_app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/creche_app/windows/runner/resources/app_icon.ico differ diff --git a/creche_app/windows/runner/runner.exe.manifest b/creche_app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/creche_app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/creche_app/windows/runner/utils.cpp b/creche_app/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/creche_app/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/creche_app/windows/runner/utils.h b/creche_app/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/creche_app/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/creche_app/windows/runner/win32_window.cpp b/creche_app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/creche_app/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/creche_app/windows/runner/win32_window.h b/creche_app/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/creche_app/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_