First version :creche-app2026

This commit is contained in:
Gelson do Souto 2026-03-11 17:58:06 +00:00
parent 4105f96c74
commit bfcd5a0db6
169 changed files with 14140 additions and 0 deletions

45
creche_app/.gitignore vendored Normal file
View File

@ -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

45
creche_app/.metadata Normal file
View File

@ -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'

6
creche_app/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"flutterTools.displayGetxContextMenu": false,
"flutterTools.displayMobxContextMenu": false,
"flutterTools.displayRiverpodContextMenu": true,
"flutterTools.displayModularContextMenu": false
}

16
creche_app/README.md Normal file
View File

@ -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.

View File

@ -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

14
creche_app/android/.gitignore vendored Normal file
View File

@ -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

View File

@ -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 = "../.."
}

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,5 @@
package Hilaritech.com.creche_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)
}

View File

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@ -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

View File

@ -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")

BIN
creche_app/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1 @@
{"v":"5.5.7","fr":30,"ip":0,"op":60,"w":200,"h":200,"layers":[]}

View File

@ -0,0 +1 @@
{"v":"5.5.7","fr":30,"ip":0,"op":60,"w":200,"h":200,"layers":[]}

34
creche_app/ios/.gitignore vendored Normal file
View File

@ -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

View File

@ -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>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -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>

View File

@ -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>

View File

@ -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)
}
}

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -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.

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -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.
}
}

View File

@ -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';
}

View File

@ -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

View File

@ -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'))),
);
}
}

View File

@ -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);

View File

@ -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)),
),
);
}
}

View File

@ -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),
),
),
],
);
},
),
),
],
),
);
}
}

View File

@ -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 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)),
])),
),
);
}

View File

@ -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(); // 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 ( 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)]),
);
}

View File

@ -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)),
),
),
]),
),
),
),
);
}
}

View File

@ -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;
}
}
}

View File

@ -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),
],
),
);
}
}

View File

@ -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'])),
]),
);
},
);
},
),
);
}
}

View File

@ -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)),
],
),
),
);
}
}

View File

@ -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))),
],
),
);
}
}

View File

@ -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()),
]);
}

View File

@ -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]);
},
);
}
}

View File

@ -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)));

View File

@ -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)));

View File

@ -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),
);
}

View File

@ -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))));
}
// 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 ( 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 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),
),
);
}

View File

@ -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)),
),
);
}

View File

@ -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)]));
}

View File

@ -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)),
));

113
creche_app/lib/main.dart Normal file
View File

@ -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,
),
);
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}

Some files were not shown because too many files have changed in this diff Show More