First version :creche-app2026
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"flutterTools.displayGetxContextMenu": false,
|
||||
"flutterTools.displayMobxContextMenu": false,
|
||||
"flutterTools.displayRiverpodContextMenu": true,
|
||||
"flutterTools.displayModularContextMenu": false
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = "../.."
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="creche_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="io.sementesfuturo.creche" />
|
||||
</intent-filter>
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package Hilaritech.com.creche_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
|
|
@ -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<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -0,0 +1 @@
|
|||
{"v":"5.5.7","fr":30,"ip":0,"op":60,"w":200,"h":200,"layers":[]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"v":"5.5.7","fr":30,"ip":0,"op":60,"w":200,"h":200,"layers":[]}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1 @@
|
|||
#include "Generated.xcconfig"
|
||||
|
|
@ -0,0 +1 @@
|
|||
#include "Generated.xcconfig"
|
||||
|
|
@ -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 = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
7
creche_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
BIN
creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
creche_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>io.sementesfuturo.creche</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Creche App</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>creche_app</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1 @@
|
|||
#import "GeneratedPluginRegistrant.h"
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<Session?> currentSession(CurrentSessionRef ref) {
|
||||
return Future.value(Supabase.instance.client.auth.currentSession);
|
||||
}
|
||||
|
||||
// Provider para o perfil do utilizador logado
|
||||
@riverpod
|
||||
Future<Profile?> 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<void> 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<void> 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<void> 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<void> _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<void> 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';
|
||||
}
|
||||
|
|
@ -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<Session?>.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<Session?>;
|
||||
|
||||
String _$currentProfileHash() => r'currentProfileHash';
|
||||
|
||||
@ProviderFor(currentProfile)
|
||||
final currentProfileProvider = AutoDisposeFutureProvider<Profile?>.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<Profile?>;
|
||||
|
||||
String _$authNotifierHash() => r'authNotifierHash';
|
||||
|
||||
@ProviderFor(AuthNotifier)
|
||||
final authNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AuthNotifier, void>.internal(
|
||||
AuthNotifier.new,
|
||||
name: r'authNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$authNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AuthNotifier = AutoDisposeAsyncNotifier<void>;
|
||||
// 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
|
||||
|
|
@ -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<GoRouter>((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<String> 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'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final supabaseProvider = Provider<SupabaseClient>((ref) => Supabase.instance.client);
|
||||
|
|
@ -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<AnnouncementsScreen> createState() =>
|
||||
_AnnouncementsScreenState();
|
||||
}
|
||||
|
||||
class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
|
||||
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<String>(
|
||||
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<void> _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<List<Map<String, dynamic>>>(
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AttendanceScreen> createState() => _AttendanceScreenState();
|
||||
}
|
||||
|
||||
class _AttendanceScreenState extends ConsumerState<AttendanceScreen> {
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
final Map<String, bool> _presence = {};
|
||||
final Map<String, String?> _timeIn = {};
|
||||
bool _isSaving = false;
|
||||
|
||||
Future<void> _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<void> _markAllPresent(List<Child> children) async {
|
||||
setState(() {
|
||||
for (final c in children) {
|
||||
_presence[c.id] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveAttendance(List<Child> 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<List<Map<String, dynamic>>>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvitePendingScreen> createState() => _State();
|
||||
}
|
||||
|
||||
class _State extends ConsumerState<InvitePendingScreen> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _anim;
|
||||
late Animation<double> _fade;
|
||||
late Animation<Offset> _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<Offset>(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<void> _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<void> _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)),
|
||||
])),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
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<double> _fadeAnim;
|
||||
late Animation<Offset> _slideAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 700));
|
||||
_fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut);
|
||||
_slideAnim = Tween<Offset>(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<void> _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<void> _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<void> _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<void> _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)]),
|
||||
);
|
||||
}
|
||||
|
|
@ -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<WaitingApprovalScreen> createState() => _State();
|
||||
}
|
||||
|
||||
class _State extends ConsumerState<WaitingApprovalScreen> {
|
||||
String? _profileId;
|
||||
bool _requesting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProfileAndRequest();
|
||||
}
|
||||
|
||||
Future<void> _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<List<Map<String, dynamic>>>(
|
||||
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)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<Map<String, dynamic>>>(
|
||||
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<Map<String, dynamic>?>(
|
||||
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<Map<String, dynamic>?> _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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
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<void> _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<void> _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<List<Map<String, dynamic>>>(
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ChildDetailScreen> createState() => _State();
|
||||
}
|
||||
|
||||
class _State extends ConsumerState<ChildDetailScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabs;
|
||||
Child? _child;
|
||||
bool _loading = true;
|
||||
bool _isNew = false;
|
||||
bool _saving = false;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 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<String> _allergyList = [];
|
||||
final List<String> _foodRestList = [];
|
||||
|
||||
// Dropdown data from DB
|
||||
List<Map<String, dynamic>> _rooms = [];
|
||||
List<Profile> _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<void> _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<Map<String, dynamic>>.from(rooms);
|
||||
_teachers = teachers.map((t) => Profile.fromMap(t)).toList();
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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<String>(
|
||||
value: _roomId,
|
||||
hint: 'Seleccionar Sala',
|
||||
icon: Icons.meeting_room_outlined,
|
||||
items: _rooms.map((r) => DropdownMenuItem<String>(
|
||||
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<String>(
|
||||
value: _teacherId,
|
||||
hint: 'Seleccionar Educadora',
|
||||
icon: Icons.supervisor_account_outlined,
|
||||
items: _teachers.map((t) => DropdownMenuItem<String>(
|
||||
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<void> _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<String> 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<List<Map<String, dynamic>>>(
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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>({T? value, required String hint, required IconData icon,
|
||||
required List<DropdownMenuItem<T>> items, required ValueChanged<T?> onChanged}) =>
|
||||
DropdownButtonFormField<T>(
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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'])),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ChildrenListScreen> createState() => _ChildrenListScreenState();
|
||||
}
|
||||
|
||||
class _ChildrenListScreenState extends ConsumerState<ChildrenListScreen> {
|
||||
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<String>(
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DiaryHistoryScreen> createState() =>
|
||||
_DiaryHistoryScreenState();
|
||||
}
|
||||
|
||||
class _DiaryHistoryScreenState extends ConsumerState<DiaryHistoryScreen> {
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NewDiaryScreen> createState() => _State();
|
||||
}
|
||||
|
||||
class _State extends ConsumerState<NewDiaryScreen> {
|
||||
final _actCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
final _instNotesCtrl = TextEditingController(); // notas da instituição
|
||||
String? _childId;
|
||||
List<Child> _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<void> _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<void> _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<String>(
|
||||
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<Widget> 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<bool> 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<String> 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()),
|
||||
]);
|
||||
}
|
||||
|
|
@ -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<List<Map<String, dynamic>>>(
|
||||
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<void> _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<void> _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<List<Map<String, dynamic>>>(
|
||||
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]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MedicationScreen> createState() => _State();
|
||||
}
|
||||
|
||||
class _State extends ConsumerState<MedicationScreen> 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<List<Map<String, dynamic>>>(
|
||||
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<List<String>>(
|
||||
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<List<String>> _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<String, dynamic> 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<String> _schedules = [];
|
||||
final _timeCtrl = TextEditingController();
|
||||
bool _loading = false;
|
||||
List<Child> _children = [];
|
||||
|
||||
@override
|
||||
void initState() { super.initState(); _loadChildren(); }
|
||||
@override
|
||||
void dispose() { _nameCtrl.dispose(); _doseCtrl.dispose(); _notesCtrl.dispose(); _timeCtrl.dispose(); super.dispose(); }
|
||||
|
||||
Future<void> _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<String, dynamic>)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<String>(
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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)));
|
||||
|
|
@ -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<MenuScreen> createState() => _State();
|
||||
}
|
||||
|
||||
class _State extends ConsumerState<MenuScreen> 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<void> _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<List<Map<String, dynamic>>>(
|
||||
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<int, List<Map<String, dynamic>>> 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<Map<String, dynamic>> 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<List<Map<String, dynamic>>>(
|
||||
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<String, List<Map<String, dynamic>>> 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<Map<String, dynamic>> 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<void> _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)));
|
||||
|
|
@ -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<PaymentsScreen> createState() => _State();
|
||||
}
|
||||
|
||||
class _State extends ConsumerState<PaymentsScreen> {
|
||||
bool _isAdmin = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkRole();
|
||||
}
|
||||
|
||||
Future<void> _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<List<Map<String, dynamic>>>(
|
||||
// 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<double>(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<void> _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<String>(
|
||||
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<String> _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<Child> _children = [];
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() { super.initState(); _loadChildren(); }
|
||||
@override
|
||||
void dispose() { _amountCtrl.dispose(); super.dispose(); }
|
||||
|
||||
Future<void> _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<void> _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<String>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ProfileScreen> createState() => _ProfileScreenState();
|
||||
}
|
||||
|
||||
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
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<void> _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<void> _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<void> _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<Widget> 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _addrCtrl = TextEditingController();
|
||||
final _slogCtrl = TextEditingController();
|
||||
final _latCtrl = TextEditingController();
|
||||
final _lngCtrl = TextEditingController();
|
||||
final _radCtrl = TextEditingController();
|
||||
final _ipCtrl = TextEditingController();
|
||||
List<String> _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<void> _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<void> _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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
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<void> _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)]));
|
||||
}
|
||||
|
|
@ -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<UsersManagementScreen> createState() => _ScreenState();
|
||||
}
|
||||
|
||||
class _ScreenState extends ConsumerState<UsersManagementScreen>
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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 = <String, List<Profile>>{};
|
||||
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<Profile> profiles;
|
||||
const _StatsRow({required this.profiles});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = <String,int>{};
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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<Map<String,dynamic>> _children = [];
|
||||
|
||||
@override
|
||||
void initState() { super.initState(); _fetchChildren(); }
|
||||
@override
|
||||
void dispose() { _eCtrl.dispose(); _pCtrl.dispose(); _nCtrl.dispose(); super.dispose(); }
|
||||
|
||||
Future<void> _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<Map<String,dynamic>>.from(d));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _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<String, dynamic>?;
|
||||
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<String>(
|
||||
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<String>(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)),
|
||||
));
|
||||
|
|
@ -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<CrecheApp> createState() => _CrecheAppState();
|
||||
}
|
||||
|
||||
class _CrecheAppState extends ConsumerState<CrecheApp> {
|
||||
final _appLinks = AppLinks();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initDeepLinks();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> toMap() {
|
||||
return {
|
||||
'title': title,
|
||||
'content': content,
|
||||
'target_role': targetRole,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> toMap() {
|
||||
return {
|
||||
'child_id': childId,
|
||||
'date': date.toIso8601String().split('T')[0],
|
||||
'status': status,
|
||||
'time_in': timeIn,
|
||||
'time_out': timeOut,
|
||||
'notes': notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<String, dynamic> map) {
|
||||
return ClassModel(
|
||||
id: map['id'] ?? '',
|
||||
name: map['name'] ?? '',
|
||||
capacity: map['capacity'] ?? 15,
|
||||
teacherId: map['teacher_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'capacity': capacity,
|
||||
'teacher_id': teacherId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
class CrecheSettings {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? logoUrl;
|
||||
final String? address;
|
||||
final String slogan;
|
||||
final List<String> 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<String, dynamic> 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<String>.from(map['allowed_ips'] ?? []),
|
||||
geofenceLat: map['geofence_lat']?.toDouble(),
|
||||
geofenceLng: map['geofence_lng']?.toDouble(),
|
||||
geofenceRadiusMeters: map['geofence_radius_meters'] ?? 150,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'logo_url': logoUrl,
|
||||
'address': address,
|
||||
'slogan': slogan,
|
||||
'allowed_ips': allowedIps,
|
||||
'geofence_lat': geofenceLat,
|
||||
'geofence_lng': geofenceLng,
|
||||
'geofence_radius_meters': geofenceRadiusMeters,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> 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<String, dynamic> 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<String>.from(map['photos'] ?? []),
|
||||
createdAt: DateTime.tryParse(map['created_at'] ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> toMap() => {
|
||||
'email': email,
|
||||
'role': role,
|
||||
'phone': phone,
|
||||
'invited_by': invitedBy,
|
||||
'status': status,
|
||||
'expires_at': expiresAt.toIso8601String(),
|
||||
'child_id': childId,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> toMap() {
|
||||
return {
|
||||
'from_user': fromUser,
|
||||
'to_user': toUser,
|
||||
'content': content,
|
||||
'is_read': isRead,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> toMap() => {
|
||||
'child_id': childId,
|
||||
'guardian_id': guardianId,
|
||||
'month': month.toIso8601String().split('T')[0],
|
||||
'amount': amount,
|
||||
'status': status,
|
||||
'paid_at': paidAt?.toIso8601String(),
|
||||
'receipt_url': receiptUrl,
|
||||
};
|
||||
}
|
||||