feat(flutter_app): initialize Phase 4 Flutter mobile application with Riverpod, Dio, GoRouter, Material 3, and live Odoo session auth

This commit is contained in:
Vidhya 2026-10-13 14:44:03 +05:30
parent a20b916aa8
commit 69d69f0420
170 changed files with 9786 additions and 0 deletions

48
flutter_app/.gitignore vendored Normal file
View File

@ -0,0 +1,48 @@
# 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
# Widget Preview related
.widget_preview/

45
flutter_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: "abaf9c523780a608bd46686fd5e53740a07077f8"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: android
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: ios
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: linux
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: macos
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: web
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: windows
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
# 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'

17
flutter_app/README.md Normal file
View File

@ -0,0 +1,17 @@
# metatron_student_erp
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@ -0,0 +1,40 @@
# 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
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
errors:
deprecated_member_use: ignore
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
flutter_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,49 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "in.metatroncube.metatron_student_erp"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "in.metatroncube.metatron_student_erp"
// 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
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
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")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
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,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="metatron_student_erp"
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">
<!-- 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 `in`.metatroncube.metatron_student_erp
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,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

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-9.3.1-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 "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")

34
flutter_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,24 @@
<?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>
</dict>
</plist>

View File

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

View File

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

View File

@ -0,0 +1,647 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
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>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; 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 = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
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 = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
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 */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.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;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
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;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<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 = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
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 = in.metatroncube.metatronStudentErp;
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 = in.metatroncube.metatronStudentErp.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 = in.metatroncube.metatronStudentErp.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 = in.metatroncube.metatronStudentErp.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 = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
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 = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
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 = in.metatroncube.metatronStudentErp;
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 = in.metatroncube.metatronStudentErp;
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 */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency 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,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<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,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

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,70 @@
<?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>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Metatron Student Erp</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>metatron_student_erp</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>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</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>
</dict>
</plist>

View File

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

View File

@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

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,172 @@
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'api_config.dart';
import 'api_exception.dart';
import '../storage/secure_storage.dart';
class ApiClient {
late final Dio _dio;
final SecureStorage _storage;
String? _sessionCookie;
void Function()? onSessionExpired;
ApiClient({
Dio? dio,
required SecureStorage storage,
}) : _storage = storage {
_dio = dio ??
Dio(
BaseOptions(
baseUrl: ApiConfig.baseUrl,
connectTimeout: ApiConfig.connectTimeout,
receiveTimeout: ApiConfig.receiveTimeout,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Dynamic base URL check in case it was updated
options.baseUrl = ApiConfig.baseUrl;
// Attach session cookie if available
_sessionCookie ??= await _storage.getSessionCookie();
if (_sessionCookie != null && _sessionCookie!.isNotEmpty) {
options.headers['Cookie'] = _sessionCookie;
}
if (kDebugMode) {
developer.log('--> ${options.method} ${options.baseUrl}${options.path}', name: 'ApiClient');
}
return handler.next(options);
},
onResponse: (response, handler) async {
// Capture Set-Cookie on login or any endpoint
final setCookieHeaders = response.headers['set-cookie'];
if (setCookieHeaders != null && setCookieHeaders.isNotEmpty) {
for (final header in setCookieHeaders) {
if (header.contains('session_id=')) {
final match = RegExp(r'session_id=[^;]+').firstMatch(header);
if (match != null) {
_sessionCookie = match.group(0);
await _storage.saveSessionCookie(_sessionCookie!);
if (kDebugMode) {
developer.log('Session cookie captured and stored securely', name: 'ApiClient');
}
}
}
}
}
return handler.next(response);
},
onError: (DioException error, handler) {
if (kDebugMode) {
developer.log('<-- ERROR ${error.message}', name: 'ApiClient', error: error);
}
return handler.next(error);
},
),
);
}
void setSessionCookie(String? cookie) {
_sessionCookie = cookie;
}
Future<void> clearSession() async {
_sessionCookie = null;
await _storage.clearSessionCookie();
}
Future<dynamic> get(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
try {
final response = await _dio.get(
path,
queryParameters: queryParameters,
);
return _handleResponse(response);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
Future<dynamic> post(
String path, {
dynamic data,
}) async {
try {
final response = await _dio.post(
path,
data: data,
);
return _handleResponse(response);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
dynamic _handleResponse(Response response) {
final data = response.data;
if (data is Map<String, dynamic>) {
final success = data['success'] == true;
if (!success) {
final error = data['error'] as Map<String, dynamic>?;
final code = error?['code']?.toString() ?? 'SERVER_ERROR';
final message = error?['message']?.toString();
if (code == 'AUTH_REQUIRED') {
onSessionExpired?.call();
}
throw ApiException.fromCode(code, customMessage: message, statusCode: response.statusCode);
}
return data['data'];
}
return data;
}
ApiException _handleDioError(DioException error) {
if (error.response != null) {
final statusCode = error.response?.statusCode;
final data = error.response?.data;
if (data is Map<String, dynamic> && data.containsKey('error')) {
final err = data['error'] as Map<String, dynamic>?;
final code = err?['code']?.toString() ?? 'SERVER_ERROR';
final message = err?['message']?.toString();
if (code == 'AUTH_REQUIRED' || statusCode == 401) {
onSessionExpired?.call();
}
return ApiException.fromCode(code, customMessage: message, statusCode: statusCode);
}
if (statusCode == 401) {
onSessionExpired?.call();
return ApiException.fromCode('AUTH_REQUIRED', statusCode: statusCode);
}
if (statusCode == 403) {
return ApiException.fromCode('STUDENT_NOT_LINKED', statusCode: statusCode);
}
return ApiException.fromCode('SERVER_ERROR', statusCode: statusCode);
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.connectionError) {
return ApiException.fromCode('NETWORK_ERROR');
}
return ApiException.fromCode('NETWORK_ERROR', customMessage: error.message);
}
}

View File

@ -0,0 +1,49 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
class ApiConfig {
static const String _envBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: '');
static String _customBaseUrl = '';
/// Returns the active API base URL.
/// Priority:
/// 1. Custom runtime configured URL (e.g. from Settings screen for physical devices)
/// 2. Compile-time --dart-define=API_BASE_URL=...
/// 3. Platform default: 10.0.2.2 on Android Emulator, localhost on iOS/macOS/Web
static String get baseUrl {
if (_customBaseUrl.isNotEmpty) {
return _customBaseUrl;
}
if (_envBaseUrl.isNotEmpty) {
return _envBaseUrl;
}
if (kIsWeb) {
return 'http://localhost:10008';
}
try {
if (Platform.isAndroid) {
// Standard Android emulator loopback alias to host machine
return 'http://10.0.2.2:10008';
}
} catch (_) {
// In tests or non-standard environments
}
return 'http://localhost:10008';
}
static set customBaseUrl(String url) {
_customBaseUrl = url.trim().replaceAll(RegExp(r'/+$'), '');
}
static void resetToDefault() {
_customBaseUrl = '';
}
// Timeouts
static const Duration connectTimeout = Duration(seconds: 15);
static const Duration receiveTimeout = Duration(seconds: 15);
}

View File

@ -0,0 +1,16 @@
class ApiEndpoints {
ApiEndpoints._();
// Authentication
static const String login = '/api/mobile/v1/auth/login';
static const String logout = '/api/mobile/v1/auth/logout';
static const String me = '/api/mobile/v1/auth/me';
// Student
static const String profile = '/api/mobile/v1/student/profile';
static const String dashboard = '/api/mobile/v1/student/dashboard';
static const String attendance = '/api/mobile/v1/student/attendance';
static const String timetable = '/api/mobile/v1/student/timetable';
static const String exams = '/api/mobile/v1/student/exams';
static const String results = '/api/mobile/v1/student/results';
}

View File

@ -0,0 +1,49 @@
class ApiException implements Exception {
final String code;
final String message;
final int? statusCode;
ApiException({
required this.code,
required this.message,
this.statusCode,
});
factory ApiException.fromCode(String code, {String? customMessage, int? statusCode}) {
String message;
switch (code) {
case 'AUTH_FAILED':
message = 'Invalid username or password';
break;
case 'AUTH_REQUIRED':
message = 'Your session has expired. Please login again.';
break;
case 'STUDENT_NOT_LINKED':
message = 'No student profile is linked to this account.';
break;
case 'NETWORK_ERROR':
message = 'Unable to connect to the ERP. Check your internet connection.';
break;
case 'SERVER_ERROR':
message = 'Something went wrong on the server. Please try again.';
break;
case 'INVALID_REQUEST':
message = 'Invalid request parameters.';
break;
case 'INVALID_DATE':
message = 'Invalid date format provided.';
break;
default:
message = customMessage ?? 'An unexpected error occurred. ($code)';
}
return ApiException(
code: code,
message: message,
statusCode: statusCode,
);
}
@override
String toString() => message;
}

View File

@ -0,0 +1,58 @@
import '../../models/user.dart';
import '../../models/student.dart';
enum AuthStatus {
initial,
loading,
authenticated,
unauthenticated,
studentNotLinked,
}
class AuthState {
final AuthStatus status;
final User? user;
final StudentBasic? student;
final String? errorMessage;
const AuthState({
this.status = AuthStatus.initial,
this.user,
this.student,
this.errorMessage,
});
bool get isAuthenticated => status == AuthStatus.authenticated;
bool get isLoading => status == AuthStatus.loading;
AuthState copyWith({
AuthStatus? status,
User? user,
StudentBasic? student,
String? errorMessage,
}) {
return AuthState(
status: status ?? this.status,
user: user ?? this.user,
student: student ?? this.student,
errorMessage: errorMessage,
);
}
factory AuthState.unauthenticated([String? error]) => AuthState(
status: AuthStatus.unauthenticated,
errorMessage: error,
);
factory AuthState.authenticated({
required User user,
StudentBasic? student,
}) =>
AuthState(
status: student != null ? AuthStatus.authenticated : AuthStatus.studentNotLinked,
user: user,
student: student,
);
factory AuthState.loading() => const AuthState(status: AuthStatus.loading);
}

View File

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../auth/auth_state.dart';
import '../../providers/auth_provider.dart';
import '../../features/splash/splash_screen.dart';
import '../../features/auth/login/login_screen.dart';
import '../../features/dashboard/dashboard_screen.dart';
import '../../features/profile/profile_screen.dart';
import '../../features/attendance/attendance_screen.dart';
import '../../features/timetable/timetable_screen.dart';
import '../../features/exams/exams_screen.dart';
import '../../features/results/results_screen.dart';
import '../../features/settings/settings_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/dashboard',
builder: (context, state) => const DashboardScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
GoRoute(
path: '/attendance',
builder: (context, state) => const AttendanceScreen(),
),
GoRoute(
path: '/timetable',
builder: (context, state) => const TimetableScreen(),
),
GoRoute(
path: '/exams',
builder: (context, state) => const ExamsScreen(),
),
GoRoute(
path: '/results',
builder: (context, state) => const ResultsScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
],
redirect: (BuildContext context, GoRouterState state) {
final authState = ref.read(authProvider);
final location = state.matchedLocation;
// Allow splash and settings anytime
if (location == '/' || location == '/settings') {
return null;
}
final isAuthenticated = authState.status == AuthStatus.authenticated;
// If user is at /login and is already authenticated, redirect to /dashboard
if (location == '/login') {
return isAuthenticated ? '/dashboard' : null;
}
// If user is trying to access protected routes but is unauthenticated, redirect to /login
if (!isAuthenticated && authState.status != AuthStatus.initial) {
return '/login';
}
return null;
},
);
});

View File

@ -0,0 +1,38 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureStorage {
static const String _keySessionCookie = 'session_cookie';
static const String _keyCustomBaseUrl = 'custom_base_url';
final FlutterSecureStorage _storage;
SecureStorage({FlutterSecureStorage? storage})
: _storage = storage ??
const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
Future<void> saveSessionCookie(String cookie) async {
await _storage.write(key: _keySessionCookie, value: cookie);
}
Future<String?> getSessionCookie() async {
return await _storage.read(key: _keySessionCookie);
}
Future<void> clearSessionCookie() async {
await _storage.delete(key: _keySessionCookie);
}
Future<void> saveCustomBaseUrl(String url) async {
await _storage.write(key: _keyCustomBaseUrl, value: url);
}
Future<String?> getCustomBaseUrl() async {
return await _storage.read(key: _keyCustomBaseUrl);
}
Future<void> clearAll() async {
await _storage.deleteAll();
}
}

View File

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
class AppTheme {
AppTheme._();
// Brand Palette
static const Color primaryColor = Color(0xFF1E3A8A); // Deep Indigo / Navy
static const Color primaryLight = Color(0xFF3B82F6); // Blue Accent
static const Color primaryDark = Color(0xFF172554);
static const Color secondaryColor = Color(0xFF0D9488); // Teal
static const Color surfaceColor = Color(0xFFF8FAFC); // Slate-50 background
static const Color cardColor = Colors.white;
static const Color errorColor = Color(0xFFDC2626);
static const Color successColor = Color(0xFF16A34A);
static const Color warningColor = Color(0xFFD97706);
static const Color textPrimary = Color(0xFF0F172A); // Slate 900
static const Color textSecondary = Color(0xFF64748B); // Slate 500
static const Color textMuted = Color(0xFF94A3B8); // Slate 400
static const Color borderLight = Color(0xFFE2E8F0); // Slate 200
static ThemeData get lightTheme {
final colorScheme = ColorScheme.fromSeed(
seedColor: primaryColor,
primary: primaryColor,
secondary: secondaryColor,
surface: surfaceColor,
error: errorColor,
brightness: Brightness.light,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: surfaceColor,
fontFamily: 'Roboto',
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: textPrimary,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
iconTheme: IconThemeData(color: textPrimary),
),
cardTheme: CardThemeData(
color: cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: borderLight, width: 1),
),
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 0),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: borderLight),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: primaryColor, width: 1.8),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: errorColor),
),
labelStyle: const TextStyle(color: textSecondary, fontSize: 14),
hintStyle: const TextStyle(color: textMuted, fontSize: 14),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primaryColor,
side: const BorderSide(color: borderLight),
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
dividerTheme: const DividerThemeData(
color: borderLight,
thickness: 1,
space: 24,
),
);
}
}

View File

@ -0,0 +1,61 @@
/// Utilities for safely converting loose or boolean/false values returned by Odoo.
/// In Odoo, empty Many2one or Char fields are frequently serialized as JSON `false`
/// instead of `null`.
class SafeConvert {
SafeConvert._();
static String? toNullableString(dynamic value) {
if (value == null || value == false) return null;
return value.toString();
}
static String toSafeString(dynamic value, [String fallback = '']) {
if (value == null || value == false) return fallback;
return value.toString();
}
static int? toNullableInt(dynamic value) {
if (value == null || value == false) return null;
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
static int toSafeInt(dynamic value, [int fallback = 0]) {
return toNullableInt(value) ?? fallback;
}
static double? toNullableDouble(dynamic value) {
if (value == null || value == false) return null;
if (value is double) return value;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
static double toSafeDouble(dynamic value, [double fallback = 0.0]) {
return toNullableDouble(value) ?? fallback;
}
static bool toSafeBool(dynamic value, [bool fallback = false]) {
if (value == null) return fallback;
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final lower = value.toLowerCase().trim();
return lower == 'true' || lower == '1';
}
return fallback;
}
static Map<String, dynamic>? toNullableMap(dynamic value) {
if (value == null || value == false || value is! Map) return null;
return Map<String, dynamic>.from(value);
}
static List<T> toSafeList<T>(dynamic value, T Function(dynamic) mapper) {
if (value == null || value == false || value is! List) return <T>[];
return value.map((item) => mapper(item)).toList();
}
}

View File

@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/attendance.dart';
import '../../providers/attendance_provider.dart';
class AttendanceScreen extends ConsumerWidget {
const AttendanceScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
const filter = AttendanceFilter();
final attendanceAsync = ref.watch(attendanceProvider(filter));
return Scaffold(
appBar: AppBar(
title: const Text('Attendance'),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(attendanceProvider(filter));
await ref.read(attendanceProvider(filter).future);
},
child: attendanceAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline_rounded, size: 48, color: AppTheme.errorColor),
const SizedBox(height: 12),
const Text('Failed to load attendance',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(err.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(attendanceProvider(filter)),
child: const Text('Retry'),
),
],
),
),
),
data: (data) => _buildAttendanceBody(context, data),
),
),
);
}
Widget _buildAttendanceBody(BuildContext context, AttendanceData data) {
final summary = data.summary;
final records = data.records;
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Attendance Overview Card
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
const Text(
'Cumulative Attendance',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'${summary.percentage.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 44,
fontWeight: FontWeight.bold,
color: summary.percentage >= 75
? AppTheme.successColor
: (summary.percentage >= 60 ? AppTheme.warningColor : AppTheme.errorColor),
),
),
const SizedBox(height: 6),
Text(
summary.percentage >= 75
? 'Eligibility: Met (Eligible for exams)'
: 'Eligibility: Shortage (< 75% required)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: summary.percentage >= 75 ? AppTheme.successColor : AppTheme.errorColor,
),
),
const Divider(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatCol('Present', summary.present, AppTheme.successColor),
_buildStatCol('Absent', summary.absent, AppTheme.errorColor),
_buildStatCol('Late', summary.late, AppTheme.warningColor),
_buildStatCol('Leave', summary.medicalLeave, Colors.purple),
_buildStatCol('Duty', summary.onDuty, Colors.blue),
],
),
],
),
),
),
const SizedBox(height: 20),
// Session History Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Attendance History',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
Text(
'${records.length} sessions recorded',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
const SizedBox(height: 10),
if (records.isEmpty)
Container(
padding: const EdgeInsets.symmetric(vertical: 40),
alignment: Alignment.center,
child: const Column(
children: [
Icon(Icons.event_busy, size: 40, color: AppTheme.textMuted),
SizedBox(height: 8),
Text(
'No attendance sessions recorded yet',
style: TextStyle(color: AppTheme.textSecondary),
),
],
),
)
else
...records.map((r) => _buildRecordCard(r)),
],
);
}
Widget _buildStatCol(String label, int count, Color color) {
return Column(
children: [
Text(
'$count',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
);
}
Widget _buildRecordCard(AttendanceRecord record) {
Color badgeColor;
Color textColor;
String label;
switch (record.status.toLowerCase()) {
case 'present':
badgeColor = AppTheme.successColor.withOpacity(0.12);
textColor = AppTheme.successColor;
label = 'PRESENT';
break;
case 'absent':
badgeColor = AppTheme.errorColor.withOpacity(0.12);
textColor = AppTheme.errorColor;
label = 'ABSENT';
break;
case 'late':
badgeColor = AppTheme.warningColor.withOpacity(0.12);
textColor = AppTheme.warningColor;
label = 'LATE';
break;
case 'medical_leave':
badgeColor = Colors.purple.withOpacity(0.12);
textColor = Colors.purple;
label = 'MEDICAL LEAVE';
break;
case 'on_duty':
badgeColor = Colors.blue.withOpacity(0.12);
textColor = Colors.blue;
label = 'ON DUTY';
break;
default:
badgeColor = Colors.grey.withOpacity(0.12);
textColor = Colors.grey;
label = record.status.toUpperCase();
}
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.calendar_today_rounded, size: 16, color: AppTheme.textSecondary),
const SizedBox(width: 8),
Text(
record.date,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeColor,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: textColor,
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,296 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/auth/auth_state.dart';
import '../../../core/theme/app_theme.dart';
import '../../../providers/auth_provider.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _fillDemoCredentials() {
setState(() {
_emailController.text = 'demo.student';
_passwordController.text = 'Demo@Student2026';
});
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
await ref.read(authProvider.notifier).login(
_emailController.text.trim(),
_passwordController.text,
);
if (!mounted) return;
final authState = ref.read(authProvider);
if (authState.status == AuthStatus.authenticated) {
context.go('/dashboard');
} else if (authState.status == AuthStatus.studentNotLinked) {
setState(() {
_errorMessage =
'Authentication successful, but no student profile is linked to this account.';
});
}
} on ApiException catch (e) {
setState(() {
_errorMessage = e.message;
});
} catch (_) {
setState(() {
_errorMessage = 'Unable to connect to the server. Please check your network.';
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Institution Logo & Header
Center(
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.08),
shape: BoxShape.circle,
),
child: const Icon(
Icons.school_rounded,
size: 40,
color: AppTheme.primaryColor,
),
),
),
const SizedBox(height: 24),
const Text(
'Welcome to Metatron ERP',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 6),
const Text(
'Sign in with your student credentials to continue',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 32),
// Error Alert
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.errorColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.errorColor.withOpacity(0.3),
),
),
child: Row(
children: [
const Icon(
Icons.error_outline_rounded,
color: AppTheme.errorColor,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(
color: AppTheme.errorColor,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(height: 20),
],
// Email/Username Field
const Text(
'Email or Username',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 6),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
hintText: 'e.g. demo.student',
prefixIcon: Icon(Icons.person_outline_rounded, size: 20),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your username or email';
}
return null;
},
),
const SizedBox(height: 18),
// Password Field
const Text(
'Password',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 6),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleLogin(),
decoration: InputDecoration(
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline_rounded, size: 20),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
size: 20,
color: AppTheme.textSecondary,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
),
const SizedBox(height: 28),
// Login Button
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Sign In'),
),
),
// Development / Demo Fill Action (Subtle helper for quick testing)
if (kDebugMode) ...[
const SizedBox(height: 20),
Center(
child: TextButton.icon(
onPressed: _fillDemoCredentials,
icon: const Icon(Icons.auto_fix_high, size: 16),
label: const Text(
'Fill Demo Credentials (Dev)',
style: TextStyle(fontSize: 12),
),
style: TextButton.styleFrom(
foregroundColor: AppTheme.textSecondary,
),
),
),
],
const SizedBox(height: 16),
Center(
child: TextButton.icon(
onPressed: () => context.push('/settings'),
icon: const Icon(Icons.settings_outlined, size: 16),
label: const Text(
'Configure Server / Connection',
style: TextStyle(fontSize: 12),
),
style: TextButton.styleFrom(
foregroundColor: AppTheme.textMuted,
),
),
),
],
),
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,749 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/attendance.dart';
import '../../models/dashboard.dart';
import '../../providers/auth_provider.dart';
import '../../providers/dashboard_provider.dart';
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dashboardAsync = ref.watch(dashboardProvider);
final authState = ref.watch(authProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Student Portal'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => context.push('/settings'),
),
IconButton(
icon: const Icon(Icons.logout_rounded),
tooltip: 'Sign Out',
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sign Out'),
content: const Text('Are you sure you want to sign out?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
),
child: const Text('Sign Out'),
),
],
),
);
if (confirm == true) {
await ref.read(authProvider.notifier).logout();
if (context.mounted) {
context.go('/login');
}
}
},
),
],
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(dashboardProvider);
await ref.read(dashboardProvider.future);
},
child: dashboardAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (err, stack) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline_rounded,
size: 48,
color: AppTheme.errorColor,
),
const SizedBox(height: 16),
const Text(
'Failed to load dashboard data',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
err.toString(),
textAlign: TextAlign.center,
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 13,
),
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => ref.refresh(dashboardProvider),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Try Again'),
),
],
),
),
),
data: (data) => _buildDashboardContent(context, data, authState.student?.name),
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: 0,
backgroundColor: Colors.white,
elevation: 2,
onDestinationSelected: (index) {
switch (index) {
case 0:
// Already on dashboard
break;
case 1:
context.push('/attendance');
break;
case 2:
context.push('/timetable');
break;
case 3:
context.push('/exams');
break;
case 4:
context.push('/profile');
break;
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard_rounded, color: AppTheme.primaryColor),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.fact_check_outlined),
selectedIcon: Icon(Icons.fact_check_rounded, color: AppTheme.primaryColor),
label: 'Attendance',
),
NavigationDestination(
icon: Icon(Icons.calendar_today_outlined),
selectedIcon: Icon(Icons.calendar_today_rounded, color: AppTheme.primaryColor),
label: 'Schedule',
),
NavigationDestination(
icon: Icon(Icons.quiz_outlined),
selectedIcon: Icon(Icons.quiz_rounded, color: AppTheme.primaryColor),
label: 'Exams',
),
NavigationDestination(
icon: Icon(Icons.person_outline_rounded),
selectedIcon: Icon(Icons.person_rounded, color: AppTheme.primaryColor),
label: 'Profile',
),
],
),
);
}
Widget _buildDashboardContent(
BuildContext context,
DashboardData data,
String? fallbackName,
) {
final studentName = data.student?.fullName.isNotEmpty == true
? data.student!.fullName
: (fallbackName ?? 'Student');
final programName = data.student?.program?.name ?? 'Enrolled Student';
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
// Student Info Banner Card
InkWell(
onTap: () => context.push('/profile'),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppTheme.primaryDark, AppTheme.primaryColor],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: Colors.white.withOpacity(0.2),
child: Text(
studentName.isNotEmpty ? studentName[0].toUpperCase() : 'S',
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
studentName,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
programName,
style: TextStyle(
color: Colors.white.withOpacity(0.85),
fontSize: 13,
),
),
],
),
),
const Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: Colors.white70,
),
],
),
),
),
const SizedBox(height: 16),
// Quick Attendance Card
_buildAttendanceCard(context, data.attendance),
const SizedBox(height: 16),
// Today's Timetable Section
_buildTimetableSection(context, data.todayTimetable),
const SizedBox(height: 16),
// Upcoming Exams Section
_buildExamsSection(context, data.upcomingExams),
const SizedBox(height: 16),
// Recent Results Section
_buildResultsSection(context, data.recentResults),
const SizedBox(height: 16),
// Announcements Section
if (data.announcements.isNotEmpty) ...[
_buildAnnouncementsSection(context, data.announcements),
const SizedBox(height: 20),
],
],
);
}
Widget _buildAttendanceCard(BuildContext context, AttendanceSummary summary) {
return Card(
child: InkWell(
onTap: () => context.push('/attendance'),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Attendance Overview',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
'View Details →',
style: TextStyle(
fontSize: 13,
color: AppTheme.primaryLight,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
// Attendance Percentage Circular Indicator
SizedBox(
width: 64,
height: 64,
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: (summary.percentage / 100).clamp(0.0, 1.0),
backgroundColor: AppTheme.borderLight,
color: summary.percentage >= 75
? AppTheme.successColor
: (summary.percentage >= 60
? AppTheme.warningColor
: AppTheme.errorColor),
strokeWidth: 6,
),
Center(
child: Text(
'${summary.percentage.toStringAsFixed(1)}%',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
),
],
),
),
const SizedBox(width: 24),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildAttendanceMetric('Present', summary.present, AppTheme.successColor),
_buildAttendanceMetric('Absent', summary.absent, AppTheme.errorColor),
_buildAttendanceMetric('Late', summary.late, AppTheme.warningColor),
],
),
),
],
),
],
),
),
),
);
}
Widget _buildAttendanceMetric(String label, int count, Color color) {
return Column(
children: [
Text(
'$count',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
);
}
Widget _buildTimetableSection(BuildContext context, List todaySlots) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Today's Schedule",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
TextButton(
onPressed: () => context.push('/timetable'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('Full Schedule →'),
),
],
),
const SizedBox(height: 12),
if (todaySlots.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.event_available_rounded, size: 28, color: AppTheme.textMuted),
SizedBox(height: 6),
Text(
'No classes scheduled today',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
)
else
...todaySlots.map(
(slot) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${slot.startTime} - ${slot.endTime}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppTheme.primaryColor,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
slot.subject?.name ?? 'Class Session',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
if (slot.room?.name != null)
Text(
slot.room!.name,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
),
),
],
),
),
);
}
Widget _buildExamsSection(BuildContext context, List<DashboardExam> upcomingExams) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Upcoming Exams',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
TextButton(
onPressed: () => context.push('/exams'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('View All →'),
),
],
),
const SizedBox(height: 12),
if (upcomingExams.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.quiz_outlined, size: 28, color: AppTheme.textMuted),
SizedBox(height: 6),
Text(
'No upcoming exams',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
)
else
...upcomingExams.map(
(exam) => ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.edit_calendar, size: 18, color: AppTheme.secondaryColor),
),
title: Text(
exam.name,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
subtitle: Text(
exam.startDate != null ? 'Date: ${exam.startDate}' : 'Scheduled',
style: const TextStyle(fontSize: 12),
),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
exam.state.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
),
),
],
),
),
);
}
Widget _buildResultsSection(BuildContext context, List<DashboardResult> recentResults) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Recent Results',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
TextButton(
onPressed: () => context.push('/results'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('View All →'),
),
],
),
const SizedBox(height: 12),
if (recentResults.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.grade_outlined, size: 28, color: AppTheme.textMuted),
SizedBox(height: 6),
Text(
'No recent results',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
)
else
...recentResults.map(
(result) => ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 16,
backgroundColor: result.status.toLowerCase() == 'pass'
? AppTheme.successColor.withOpacity(0.1)
: AppTheme.errorColor.withOpacity(0.1),
child: Text(
result.grade ?? (result.status.toLowerCase() == 'pass' ? 'P' : 'F'),
style: TextStyle(
color: result.status.toLowerCase() == 'pass'
? AppTheme.successColor
: AppTheme.errorColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
title: Text(
result.exam?.name ?? 'Exam Result',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
subtitle: Text(
'Score: ${result.percentage.toStringAsFixed(1)}%',
style: const TextStyle(fontSize: 12),
),
trailing: Text(
result.status.toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: result.status.toLowerCase() == 'pass'
? AppTheme.successColor
: AppTheme.errorColor,
),
),
),
),
],
),
),
);
}
Widget _buildAnnouncementsSection(BuildContext context, List<Announcement> announcements) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.campaign_outlined, color: AppTheme.primaryColor, size: 20),
SizedBox(width: 8),
Text(
'Announcements',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
...announcements.map(
(ann) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
ann.name,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
if (ann.date.isNotEmpty)
Text(
ann.date,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
if (ann.summary.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
ann.summary,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
],
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/exam.dart';
import '../../providers/exam_provider.dart';
class ExamsScreen extends ConsumerWidget {
const ExamsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final examsAsync = ref.watch(examProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Exams & Assessments'),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(examProvider);
await ref.read(examProvider.future);
},
child: examsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline_rounded, size: 48, color: AppTheme.errorColor),
const SizedBox(height: 12),
const Text('Failed to load exams',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(err.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(examProvider),
child: const Text('Retry'),
),
],
),
),
),
data: (exams) {
if (exams.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.quiz_outlined, size: 60, color: AppTheme.textMuted),
SizedBox(height: 12),
Text(
'No upcoming exams',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'There are no examinations scheduled at this time.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: exams.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) => _buildExamCard(exams[index]),
);
},
),
),
);
}
Widget _buildExamCard(Exam exam) {
Color badgeBg;
Color badgeFg;
switch (exam.state.toLowerCase()) {
case 'ongoing':
badgeBg = AppTheme.warningColor.withOpacity(0.12);
badgeFg = AppTheme.warningColor;
break;
case 'completed':
badgeBg = AppTheme.successColor.withOpacity(0.12);
badgeFg = AppTheme.successColor;
break;
case 'scheduled':
default:
badgeBg = Colors.blue.withOpacity(0.12);
badgeFg = Colors.blue.shade700;
break;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
exam.name,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: badgeBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
exam.state.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: badgeFg,
),
),
),
],
),
const SizedBox(height: 12),
if (exam.startDate != null)
Row(
children: [
const Icon(Icons.event_note, size: 16, color: AppTheme.textSecondary),
const SizedBox(width: 6),
Text(
'Start Date: ${exam.startDate!}${exam.endDate != null ? ' - ${exam.endDate!}' : ''}',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
],
),
if (exam.program?.name != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.school, size: 16, color: AppTheme.textSecondary),
const SizedBox(width: 6),
Text(
exam.program!.name,
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
],
),
],
],
),
),
);
}
}

View File

@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/student.dart';
import '../../providers/dashboard_provider.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(studentProfileProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Student Profile'),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(studentProfileProvider);
await ref.read(studentProfileProvider.future);
},
child: profileAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline_rounded, size: 48, color: AppTheme.errorColor),
const SizedBox(height: 12),
const Text(
'Failed to load profile details',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
err.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(studentProfileProvider),
child: const Text('Retry'),
),
],
),
),
),
data: (profile) => _buildProfileBody(context, profile),
),
),
);
}
Widget _buildProfileBody(BuildContext context, StudentProfile profile) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Profile Header Card
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
CircleAvatar(
radius: 36,
backgroundColor: AppTheme.primaryColor,
child: Text(
profile.fullName.isNotEmpty ? profile.fullName[0].toUpperCase() : 'S',
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
Text(
profile.fullName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primaryLight.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'ID: ${profile.studentId}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
),
),
),
],
),
),
),
const SizedBox(height: 16),
// Academic Information Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.school_outlined, size: 20, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text(
'Academic Details',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const Divider(),
_buildInfoTile('Admission Number', profile.admissionNumber),
if (profile.rollNumber != null) _buildInfoTile('Roll Number', profile.rollNumber!),
if (profile.program?.name != null) _buildInfoTile('Program', profile.program!.name),
if (profile.department?.name != null)
_buildInfoTile('Department', profile.department!.name),
if (profile.level?.name != null) _buildInfoTile('Level', profile.level!.name),
if (profile.academicYear?.name != null)
_buildInfoTile('Academic Year', profile.academicYear!.name),
if (profile.academicTerm?.name != null)
_buildInfoTile('Academic Term', profile.academicTerm!.name),
if (profile.batch?.name != null) _buildInfoTile('Batch', profile.batch!.name),
if (profile.semester?.name != null)
_buildInfoTile('Semester', profile.semester!.name),
if (profile.section?.name != null) _buildInfoTile('Section', profile.section!.name),
if (profile.campus?.name != null) _buildInfoTile('Campus', profile.campus!.name),
_buildInfoTile('Status', profile.status.toUpperCase()),
],
),
),
),
const SizedBox(height: 16),
// Personal Information Card
if (profile.email != null ||
profile.mobile != null ||
profile.gender != null ||
profile.dateOfBirth != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.badge_outlined, size: 20, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text(
'Personal Information',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const Divider(),
if (profile.email != null) _buildInfoTile('Email', profile.email!),
if (profile.mobile != null) _buildInfoTile('Mobile', profile.mobile!),
if (profile.gender != null)
_buildInfoTile('Gender', profile.gender!.toUpperCase()),
if (profile.dateOfBirth != null)
_buildInfoTile('Date of Birth', profile.dateOfBirth!),
],
),
),
),
],
);
}
Widget _buildInfoTile(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(
label,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/result.dart';
import '../../providers/result_provider.dart';
class ResultsScreen extends ConsumerWidget {
const ResultsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final resultsAsync = ref.watch(resultProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Examination Results'),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(resultProvider);
await ref.read(resultProvider.future);
},
child: resultsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline_rounded, size: 48, color: AppTheme.errorColor),
const SizedBox(height: 12),
const Text('Failed to load examination results',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(err.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(resultProvider),
child: const Text('Retry'),
),
],
),
),
),
data: (results) {
if (results.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.grade_outlined, size: 60, color: AppTheme.textMuted),
SizedBox(height: 12),
Text(
'No published results',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'Your exam results will appear here once published by the controller of examinations.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: results.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) => _buildResultCard(results[index]),
);
},
),
),
);
}
Widget _buildResultCard(ExamResult result) {
final isPass = result.status.toLowerCase() == 'pass';
return Card(
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: CircleAvatar(
backgroundColor: isPass
? AppTheme.successColor.withOpacity(0.12)
: AppTheme.errorColor.withOpacity(0.12),
child: Text(
result.grade ?? (isPass ? 'P' : 'F'),
style: TextStyle(
fontWeight: FontWeight.bold,
color: isPass ? AppTheme.successColor : AppTheme.errorColor,
),
),
),
title: Text(
result.exam?.name ?? 'Examination',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Score: ${result.totalObtainedMarks.toStringAsFixed(0)} / ${result.totalMaxMarks.toStringAsFixed(0)} (${result.percentage.toStringAsFixed(1)}%)',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: isPass
? AppTheme.successColor.withOpacity(0.12)
: AppTheme.errorColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
result.status.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isPass ? AppTheme.successColor : AppTheme.errorColor,
),
),
),
children: [
const Divider(height: 1),
if (result.lines.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'No detailed subject breakdown available',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
)
else
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Subject Marks Breakdown',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 10),
...result.lines.map((line) => _buildLineRow(line)),
],
),
),
],
),
);
}
Widget _buildLineRow(ResultLine line) {
final linePass = line.status.toLowerCase() == 'pass';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
line.subject?.name ?? 'Subject',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
Text(
'Max: ${line.maxMarks.toStringAsFixed(0)} | Min Pass: ${line.passMarks.toStringAsFixed(0)}',
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${line.obtainedMarks.toStringAsFixed(0)} (${line.percentage.toStringAsFixed(0)}%)',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
Text(
line.grade != null ? 'Grade: ${line.grade}' : line.status.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: linePass ? AppTheme.successColor : AppTheme.errorColor,
),
),
],
),
],
),
);
}
}

View File

@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/api/api_config.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final _urlController = TextEditingController();
@override
void initState() {
super.initState();
_urlController.text = ApiConfig.baseUrl;
}
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
Future<void> _saveBaseUrl() async {
final newUrl = _urlController.text.trim();
if (newUrl.isEmpty) return;
ApiConfig.customBaseUrl = newUrl;
final storage = ref.read(secureStorageProvider);
await storage.saveCustomBaseUrl(newUrl);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('API Server URL updated to: $newUrl'),
backgroundColor: AppTheme.successColor,
),
);
}
}
Future<void> _resetBaseUrl() async {
ApiConfig.resetToDefault();
final storage = ref.read(secureStorageProvider);
await storage.saveCustomBaseUrl('');
setState(() {
_urlController.text = ApiConfig.baseUrl;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('API Server URL reset to default: ${ApiConfig.baseUrl}'),
),
);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Settings & Configuration'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Connection & API Server Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.dns_outlined, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text(
'Backend Server Configuration',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 8),
const Text(
'Set the Odoo API Server address. Use your Mac IP (e.g. http://192.168.1.5:10008) when testing on a physical phone.',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
const SizedBox(height: 14),
TextField(
controller: _urlController,
decoration: InputDecoration(
labelText: 'API Base URL',
hintText: 'http://localhost:10008',
suffixIcon: IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Reset to default',
onPressed: _resetBaseUrl,
),
),
),
const SizedBox(height: 12),
Row(
children: [
ElevatedButton.icon(
onPressed: _saveBaseUrl,
icon: const Icon(Icons.save, size: 16),
label: const Text('Apply & Save'),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: () {
// Quick preset: Android Emulator
setState(() {
_urlController.text = 'http://10.0.2.2:10008';
});
},
child: const Text('Android 10.0.2.2'),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// User Session Info Card
if (authState.user != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.account_circle_outlined, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text(
'Active Session',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const Divider(),
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: const Text('Logged in user'),
subtitle: Text(
'${authState.user!.name} (${authState.user!.email})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (authState.student != null)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: const Text('Linked Student Profile'),
subtitle: Text(
'${authState.student!.name} (ID: ${authState.student!.id})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () async {
await ref.read(authProvider.notifier).logout();
if (context.mounted) {
context.go('/login');
}
},
icon: const Icon(Icons.logout),
label: const Text('Sign Out'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
),
),
),
],
),
),
),
const SizedBox(height: 16),
// Application Info Card
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Application Info',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('Metatron Education ERP Mobile',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
Text('Version 1.0.0+1 (Phase 4 Flutter Build)',
style: TextStyle(fontSize: 12, color: AppTheme.textMuted)),
SizedBox(height: 4),
Text('Backend: Odoo 19 + education_mobile_api',
style: TextStyle(fontSize: 12, color: AppTheme.textMuted)),
],
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_state.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
// Trigger session restoration on startup
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkSession();
});
}
Future<void> _checkSession() async {
await ref.read(authProvider.notifier).restoreSession();
if (!mounted) return;
final authState = ref.read(authProvider);
if (authState.status == AuthStatus.authenticated) {
context.go('/dashboard');
} else {
context.go('/login');
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(
backgroundColor: AppTheme.primaryDark,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.school_rounded,
size: 80,
color: Colors.white,
),
SizedBox(height: 24),
Text(
'Metatron Student ERP',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
SizedBox(height: 8),
Text(
'Education Management System',
style: TextStyle(
color: AppTheme.textMuted,
fontSize: 14,
),
),
SizedBox(height: 48),
CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
],
),
),
);
}
}

View File

@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/timetable.dart';
import '../../providers/timetable_provider.dart';
class TimetableScreen extends ConsumerWidget {
const TimetableScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final timetableAsync = ref.watch(timetableProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Weekly Timetable'),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(timetableProvider);
await ref.read(timetableProvider.future);
},
child: timetableAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline_rounded, size: 48, color: AppTheme.errorColor),
const SizedBox(height: 12),
const Text('Failed to load timetable',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(err.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(timetableProvider),
child: const Text('Retry'),
),
],
),
),
),
data: (slots) {
if (slots.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.calendar_month_outlined, size: 60, color: AppTheme.textMuted),
SizedBox(height: 12),
Text(
'No timetable slots scheduled',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'Your class schedule has not been published yet.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
),
);
}
// Group slots by Day or Date
final Map<String, List<TimetableSlot>> grouped = {};
for (final slot in slots) {
final key = slot.day.isNotEmpty
? slot.day
: (slot.date.isNotEmpty ? slot.date : 'Scheduled Classes');
grouped.putIfAbsent(key, () => []).add(slot);
}
return ListView(
padding: const EdgeInsets.all(16),
children: grouped.entries.map((entry) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Text(
entry.key.toUpperCase(),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
letterSpacing: 0.5,
),
),
),
...entry.value.map((slot) => _buildSlotCard(slot)),
const SizedBox(height: 12),
],
);
}).toList(),
);
},
),
),
);
}
Widget _buildSlotCard(TimetableSlot slot) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
slot.startTime,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.primaryDark,
),
),
const Text('to', style: TextStyle(fontSize: 10, color: AppTheme.textSecondary)),
Text(
slot.endTime,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.primaryDark,
),
),
],
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
slot.subject?.name ?? 'Lecture Session',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 6),
if (slot.faculty?.name != null)
Row(
children: [
const Icon(Icons.person_outline, size: 14, color: AppTheme.textSecondary),
const SizedBox(width: 4),
Text(
slot.faculty!.name,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
if (slot.room?.name != null) ...[
const SizedBox(height: 3),
Row(
children: [
const Icon(Icons.meeting_room_outlined,
size: 14, color: AppTheme.textSecondary),
const SizedBox(width: 4),
Text(
slot.room!.name,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
],
],
),
),
],
),
),
);
}
}

39
flutter_app/lib/main.dart Normal file
View File

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/api/api_config.dart';
import 'core/router/app_router.dart';
import 'core/storage/secure_storage.dart';
import 'core/theme/app_theme.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Load custom API Base URL if previously configured in Settings
final storage = SecureStorage();
final customUrl = await storage.getCustomBaseUrl();
if (customUrl != null && customUrl.isNotEmpty) {
ApiConfig.customBaseUrl = customUrl;
}
runApp(
const ProviderScope(
child: MetatronStudentApp(),
),
);
}
class MetatronStudentApp extends ConsumerWidget {
const MetatronStudentApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'Metatron Student ERP',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
routerConfig: router,
);
}
}

View File

@ -0,0 +1,80 @@
import '../core/utils/safe_convert.dart';
class AttendanceSummary {
final double percentage;
final int present;
final int absent;
final int late;
final int medicalLeave;
final int onDuty;
const AttendanceSummary({
required this.percentage,
required this.present,
required this.absent,
required this.late,
required this.medicalLeave,
required this.onDuty,
});
factory AttendanceSummary.fromJson(Map<String, dynamic> json) {
return AttendanceSummary(
percentage: SafeConvert.toSafeDouble(json['percentage']),
present: SafeConvert.toSafeInt(json['present']),
absent: SafeConvert.toSafeInt(json['absent']),
late: SafeConvert.toSafeInt(json['late']),
medicalLeave: SafeConvert.toSafeInt(json['medical_leave']),
onDuty: SafeConvert.toSafeInt(json['on_duty']),
);
}
factory AttendanceSummary.empty() => const AttendanceSummary(
percentage: 0.0,
present: 0,
absent: 0,
late: 0,
medicalLeave: 0,
onDuty: 0,
);
}
class AttendanceRecord {
final int id;
final String date;
final String status;
const AttendanceRecord({
required this.id,
required this.date,
required this.status,
});
factory AttendanceRecord.fromJson(Map<String, dynamic> json) {
return AttendanceRecord(
id: SafeConvert.toSafeInt(json['id']),
date: SafeConvert.toSafeString(json['date']),
status: SafeConvert.toSafeString(json['status'], 'absent'),
);
}
}
class AttendanceData {
final AttendanceSummary summary;
final List<AttendanceRecord> records;
const AttendanceData({
required this.summary,
required this.records,
});
factory AttendanceData.fromJson(Map<String, dynamic> json) {
final summaryMap = SafeConvert.toNullableMap(json['summary']) ?? {};
return AttendanceData(
summary: AttendanceSummary.fromJson(summaryMap),
records: SafeConvert.toSafeList(
json['records'],
(item) => AttendanceRecord.fromJson(SafeConvert.toNullableMap(item) ?? {}),
),
);
}
}

View File

@ -0,0 +1,140 @@
import '../core/utils/safe_convert.dart';
import 'attendance.dart';
import 'student.dart';
import 'timetable.dart';
class Announcement {
final int id;
final String name;
final String date;
final String summary;
const Announcement({
required this.id,
required this.name,
required this.date,
required this.summary,
});
factory Announcement.fromJson(Map<String, dynamic> json) {
return Announcement(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name']),
date: SafeConvert.toSafeString(json['date']),
summary: SafeConvert.toSafeString(json['summary']),
);
}
}
class DashboardExam {
final int id;
final String name;
final String state;
final String? startDate;
const DashboardExam({
required this.id,
required this.name,
required this.state,
this.startDate,
});
factory DashboardExam.fromJson(Map<String, dynamic> json) {
return DashboardExam(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name']),
state: SafeConvert.toSafeString(json['state'], 'scheduled'),
startDate: SafeConvert.toNullableString(json['start_date']),
);
}
}
class DashboardResult {
final int id;
final NamedRecord? exam;
final double percentage;
final String? grade;
final String status;
const DashboardResult({
required this.id,
this.exam,
required this.percentage,
this.grade,
required this.status,
});
factory DashboardResult.fromJson(Map<String, dynamic> json) {
return DashboardResult(
id: SafeConvert.toSafeInt(json['id']),
exam: NamedRecord.fromNullableJson(json['exam']),
percentage: SafeConvert.toSafeDouble(json['percentage']),
grade: SafeConvert.toNullableString(json['grade']),
status: SafeConvert.toSafeString(json['status'], 'pass'),
);
}
}
class DashboardStudent {
final int id;
final String fullName;
final NamedRecord? program;
const DashboardStudent({
required this.id,
required this.fullName,
this.program,
});
factory DashboardStudent.fromJson(Map<String, dynamic> json) {
return DashboardStudent(
id: SafeConvert.toSafeInt(json['id']),
fullName: SafeConvert.toSafeString(json['full_name']),
program: NamedRecord.fromNullableJson(json['program']),
);
}
}
class DashboardData {
final DashboardStudent? student;
final AttendanceSummary attendance;
final List<TimetableSlot> todayTimetable;
final List<DashboardExam> upcomingExams;
final List<DashboardResult> recentResults;
final List<Announcement> announcements;
const DashboardData({
this.student,
required this.attendance,
required this.todayTimetable,
required this.upcomingExams,
required this.recentResults,
required this.announcements,
});
factory DashboardData.fromJson(Map<String, dynamic> json) {
final studentMap = SafeConvert.toNullableMap(json['student']);
final attendanceMap = SafeConvert.toNullableMap(json['attendance']) ?? {};
return DashboardData(
student: studentMap != null ? DashboardStudent.fromJson(studentMap) : null,
attendance: AttendanceSummary.fromJson(attendanceMap),
todayTimetable: SafeConvert.toSafeList(
json['today_timetable'],
(item) => TimetableSlot.fromJson(SafeConvert.toNullableMap(item) ?? {}),
),
upcomingExams: SafeConvert.toSafeList(
json['upcoming_exams'],
(item) => DashboardExam.fromJson(SafeConvert.toNullableMap(item) ?? {}),
),
recentResults: SafeConvert.toSafeList(
json['recent_results'],
(item) => DashboardResult.fromJson(SafeConvert.toNullableMap(item) ?? {}),
),
announcements: SafeConvert.toSafeList(
json['announcements'],
(item) => Announcement.fromJson(SafeConvert.toNullableMap(item) ?? {}),
),
);
}
}

View File

@ -0,0 +1,34 @@
import '../core/utils/safe_convert.dart';
import 'student.dart';
class Exam {
final int id;
final String name;
final String state;
final String? startDate;
final String? endDate;
final NamedRecord? program;
final NamedRecord? academicYear;
const Exam({
required this.id,
required this.name,
required this.state,
this.startDate,
this.endDate,
this.program,
this.academicYear,
});
factory Exam.fromJson(Map<String, dynamic> json) {
return Exam(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name']),
state: SafeConvert.toSafeString(json['state'], 'scheduled'),
startDate: SafeConvert.toNullableString(json['start_date']),
endDate: SafeConvert.toNullableString(json['end_date']),
program: NamedRecord.fromNullableJson(json['program']),
academicYear: NamedRecord.fromNullableJson(json['academic_year']),
);
}
}

View File

@ -0,0 +1,75 @@
import '../core/utils/safe_convert.dart';
import 'student.dart';
class ResultLine {
final NamedRecord? subject;
final double maxMarks;
final double passMarks;
final double obtainedMarks;
final double percentage;
final String? grade;
final String status;
const ResultLine({
this.subject,
required this.maxMarks,
required this.passMarks,
required this.obtainedMarks,
required this.percentage,
this.grade,
required this.status,
});
factory ResultLine.fromJson(Map<String, dynamic> json) {
return ResultLine(
subject: NamedRecord.fromNullableJson(json['subject']),
maxMarks: SafeConvert.toSafeDouble(json['max_marks']),
passMarks: SafeConvert.toSafeDouble(json['pass_marks']),
obtainedMarks: SafeConvert.toSafeDouble(json['obtained_marks']),
percentage: SafeConvert.toSafeDouble(json['percentage']),
grade: SafeConvert.toNullableString(json['grade']),
status: SafeConvert.toSafeString(json['status'], 'pass'),
);
}
}
class ExamResult {
final int id;
final NamedRecord? exam;
final double totalMaxMarks;
final double totalObtainedMarks;
final double percentage;
final String? grade;
final String status;
final String? publishedDate;
final List<ResultLine> lines;
const ExamResult({
required this.id,
this.exam,
required this.totalMaxMarks,
required this.totalObtainedMarks,
required this.percentage,
this.grade,
required this.status,
this.publishedDate,
required this.lines,
});
factory ExamResult.fromJson(Map<String, dynamic> json) {
return ExamResult(
id: SafeConvert.toSafeInt(json['id']),
exam: NamedRecord.fromNullableJson(json['exam']),
totalMaxMarks: SafeConvert.toSafeDouble(json['total_max_marks']),
totalObtainedMarks: SafeConvert.toSafeDouble(json['total_obtained_marks']),
percentage: SafeConvert.toSafeDouble(json['percentage']),
grade: SafeConvert.toNullableString(json['grade']),
status: SafeConvert.toSafeString(json['status'], 'pass'),
publishedDate: SafeConvert.toNullableString(json['published_date']),
lines: SafeConvert.toSafeList(
json['lines'],
(item) => ResultLine.fromJson(SafeConvert.toNullableMap(item) ?? {}),
),
);
}
}

View File

@ -0,0 +1,117 @@
import '../core/utils/safe_convert.dart';
class NamedRecord {
final int id;
final String name;
const NamedRecord({required this.id, required this.name});
factory NamedRecord.fromJson(Map<String, dynamic> json) {
return NamedRecord(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name']),
);
}
static NamedRecord? fromNullableJson(dynamic json) {
final map = SafeConvert.toNullableMap(json);
if (map == null) return null;
return NamedRecord.fromJson(map);
}
Map<String, dynamic> toJson() => {'id': id, 'name': name};
@override
String toString() => name;
}
class StudentBasic {
final int id;
final String name;
const StudentBasic({required this.id, required this.name});
factory StudentBasic.fromJson(Map<String, dynamic> json) {
return StudentBasic(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name']),
);
}
static StudentBasic? fromNullableJson(dynamic json) {
final map = SafeConvert.toNullableMap(json);
if (map == null) return null;
return StudentBasic.fromJson(map);
}
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class StudentProfile {
final int id;
final String studentId;
final String admissionNumber;
final String? rollNumber;
final String fullName;
final String? email;
final String? mobile;
final String? gender;
final String? dateOfBirth;
final String status;
final NamedRecord? campus;
final NamedRecord? department;
final NamedRecord? program;
final NamedRecord? level;
final NamedRecord? academicYear;
final NamedRecord? academicTerm;
final NamedRecord? batch;
final NamedRecord? semester;
final NamedRecord? section;
const StudentProfile({
required this.id,
required this.studentId,
required this.admissionNumber,
this.rollNumber,
required this.fullName,
this.email,
this.mobile,
this.gender,
this.dateOfBirth,
required this.status,
this.campus,
this.department,
this.program,
this.level,
this.academicYear,
this.academicTerm,
this.batch,
this.semester,
this.section,
});
factory StudentProfile.fromJson(Map<String, dynamic> json) {
return StudentProfile(
id: SafeConvert.toSafeInt(json['id']),
studentId: SafeConvert.toSafeString(json['student_id']),
admissionNumber: SafeConvert.toSafeString(json['admission_number']),
rollNumber: SafeConvert.toNullableString(json['roll_number']),
fullName: SafeConvert.toSafeString(json['full_name']),
email: SafeConvert.toNullableString(json['email']),
mobile: SafeConvert.toNullableString(json['mobile']),
gender: SafeConvert.toNullableString(json['gender']),
dateOfBirth: SafeConvert.toNullableString(json['date_of_birth']),
status: SafeConvert.toSafeString(json['status'], 'draft'),
campus: NamedRecord.fromNullableJson(json['campus']),
department: NamedRecord.fromNullableJson(json['department']),
program: NamedRecord.fromNullableJson(json['program']),
level: NamedRecord.fromNullableJson(json['level']),
academicYear: NamedRecord.fromNullableJson(json['academic_year']),
academicTerm: NamedRecord.fromNullableJson(json['academic_term']),
batch: NamedRecord.fromNullableJson(json['batch']),
semester: NamedRecord.fromNullableJson(json['semester']),
section: NamedRecord.fromNullableJson(json['section']),
);
}
}

View File

@ -0,0 +1,37 @@
import '../core/utils/safe_convert.dart';
import 'student.dart';
class TimetableSlot {
final int id;
final String date;
final String day;
final String startTime;
final String endTime;
final NamedRecord? subject;
final NamedRecord? faculty;
final NamedRecord? room;
const TimetableSlot({
required this.id,
required this.date,
required this.day,
required this.startTime,
required this.endTime,
this.subject,
this.faculty,
this.room,
});
factory TimetableSlot.fromJson(Map<String, dynamic> json) {
return TimetableSlot(
id: SafeConvert.toSafeInt(json['id']),
date: SafeConvert.toSafeString(json['date']),
day: SafeConvert.toSafeString(json['day']),
startTime: SafeConvert.toSafeString(json['start_time']),
endTime: SafeConvert.toSafeString(json['end_time']),
subject: NamedRecord.fromNullableJson(json['subject']),
faculty: NamedRecord.fromNullableJson(json['faculty']),
room: NamedRecord.fromNullableJson(json['room']),
);
}
}

View File

@ -0,0 +1,33 @@
import '../core/utils/safe_convert.dart';
class User {
final int id;
final String name;
final String email;
final String role;
const User({
required this.id,
required this.name,
required this.email,
required this.role,
});
bool get isStudent => role == 'student';
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name']),
email: SafeConvert.toSafeString(json['email']),
role: SafeConvert.toSafeString(json['role'], 'user'),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
'role': role,
};
}

View File

@ -0,0 +1,36 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/attendance.dart';
import '../repositories/attendance_repository.dart';
import 'auth_provider.dart';
final attendanceRepositoryProvider = Provider<AttendanceRepository>((ref) {
final apiClient = ref.watch(apiClientProvider);
return AttendanceRepository(apiClient: apiClient);
});
class AttendanceFilter {
final String? fromDate;
final String? toDate;
const AttendanceFilter({this.fromDate, this.toDate});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AttendanceFilter &&
runtimeType == other.runtimeType &&
fromDate == other.fromDate &&
toDate == other.toDate;
@override
int get hashCode => fromDate.hashCode ^ toDate.hashCode;
}
final attendanceProvider = FutureProvider.autoDispose
.family<AttendanceData, AttendanceFilter>((ref, filter) async {
final repository = ref.watch(attendanceRepositoryProvider);
return await repository.getAttendance(
fromDate: filter.fromDate,
toDate: filter.toDate,
);
});

View File

@ -0,0 +1,110 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/api_client.dart';
import '../core/api/api_exception.dart';
import '../core/auth/auth_state.dart';
import '../core/storage/secure_storage.dart';
import '../repositories/auth_repository.dart';
// Core Service Providers
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
final apiClientProvider = Provider<ApiClient>((ref) {
final storage = ref.watch(secureStorageProvider);
return ApiClient(storage: storage);
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final apiClient = ref.watch(apiClientProvider);
return AuthRepository(apiClient: apiClient);
});
// Auth State Notifier
class AuthNotifier extends StateNotifier<AuthState> {
final AuthRepository _authRepository;
final SecureStorage _storage;
final ApiClient _apiClient;
AuthNotifier({
required AuthRepository authRepository,
required SecureStorage storage,
required ApiClient apiClient,
}) : _authRepository = authRepository,
_storage = storage,
_apiClient = apiClient,
super(const AuthState(status: AuthStatus.initial)) {
// Hook into session expiration cleanly without cyclic provider dependency
_apiClient.onSessionExpired = handleSessionExpired;
}
/// Checks if a valid session exists in storage on app startup.
Future<void> restoreSession() async {
state = AuthState.loading();
try {
final sessionCookie = await _storage.getSessionCookie();
if (sessionCookie == null || sessionCookie.isEmpty) {
state = AuthState.unauthenticated();
return;
}
// Validate session with backend
final authResponse = await _authRepository.getMe();
state = AuthState.authenticated(
user: authResponse.user,
student: authResponse.student,
);
} catch (e) {
// If server returns AUTH_REQUIRED or network fails, unauthenticate
await _storage.clearSessionCookie();
state = AuthState.unauthenticated();
}
}
Future<void> login(String email, String password) async {
state = AuthState.loading();
try {
final authResponse = await _authRepository.login(
email: email,
password: password,
);
state = AuthState.authenticated(
user: authResponse.user,
student: authResponse.student,
);
} on ApiException catch (e) {
state = AuthState.unauthenticated(e.message);
rethrow;
} catch (e) {
state = AuthState.unauthenticated('An unexpected error occurred during login.');
rethrow;
}
}
Future<void> logout() async {
try {
await _authRepository.logout();
} catch (_) {
// Even if network logout fails, clear local session
} finally {
await _storage.clearSessionCookie();
state = AuthState.unauthenticated();
}
}
void handleSessionExpired() {
state = AuthState.unauthenticated('Your session has expired. Please login again.');
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final authRepo = ref.watch(authRepositoryProvider);
final storage = ref.watch(secureStorageProvider);
final client = ref.watch(apiClientProvider);
return AuthNotifier(
authRepository: authRepo,
storage: storage,
apiClient: client,
);
});

View File

@ -0,0 +1,20 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/dashboard.dart';
import '../models/student.dart';
import '../repositories/student_repository.dart';
import 'auth_provider.dart';
final studentRepositoryProvider = Provider<StudentRepository>((ref) {
final apiClient = ref.watch(apiClientProvider);
return StudentRepository(apiClient: apiClient);
});
final dashboardProvider = FutureProvider.autoDispose<DashboardData>((ref) async {
final repository = ref.watch(studentRepositoryProvider);
return await repository.getDashboard();
});
final studentProfileProvider = FutureProvider.autoDispose<StudentProfile>((ref) async {
final repository = ref.watch(studentRepositoryProvider);
return await repository.getProfile();
});

View File

@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/exam.dart';
import '../repositories/exam_repository.dart';
import 'auth_provider.dart';
final examRepositoryProvider = Provider<ExamRepository>((ref) {
final apiClient = ref.watch(apiClientProvider);
return ExamRepository(apiClient: apiClient);
});
final examProvider = FutureProvider.autoDispose<List<Exam>>((ref) async {
final repository = ref.watch(examRepositoryProvider);
return await repository.getExams();
});

View File

@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/result.dart';
import '../repositories/result_repository.dart';
import 'auth_provider.dart';
final resultRepositoryProvider = Provider<ResultRepository>((ref) {
final apiClient = ref.watch(apiClientProvider);
return ResultRepository(apiClient: apiClient);
});
final resultProvider = FutureProvider.autoDispose<List<ExamResult>>((ref) async {
final repository = ref.watch(resultRepositoryProvider);
return await repository.getResults();
});

View File

@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/timetable.dart';
import '../repositories/timetable_repository.dart';
import 'auth_provider.dart';
final timetableRepositoryProvider = Provider<TimetableRepository>((ref) {
final apiClient = ref.watch(apiClientProvider);
return TimetableRepository(apiClient: apiClient);
});
final timetableProvider = FutureProvider.autoDispose<List<TimetableSlot>>((ref) async {
final repository = ref.watch(timetableRepositoryProvider);
return await repository.getTimetable();
});

View File

@ -0,0 +1,23 @@
import '../core/api/api_client.dart';
import '../core/api/api_endpoints.dart';
import '../core/utils/safe_convert.dart';
import '../models/attendance.dart';
class AttendanceRepository {
final ApiClient _apiClient;
AttendanceRepository({required ApiClient apiClient}) : _apiClient = apiClient;
Future<AttendanceData> getAttendance({String? fromDate, String? toDate}) async {
final query = <String, dynamic>{};
if (fromDate != null) query['from_date'] = fromDate;
if (toDate != null) query['to_date'] = toDate;
final data = await _apiClient.get(
ApiEndpoints.attendance,
queryParameters: query.isNotEmpty ? query : null,
);
return AttendanceData.fromJson(SafeConvert.toNullableMap(data) ?? {});
}
}

View File

@ -0,0 +1,56 @@
import '../core/api/api_client.dart';
import '../core/api/api_endpoints.dart';
import '../core/utils/safe_convert.dart';
import '../models/student.dart';
import '../models/user.dart';
class AuthResponse {
final User user;
final StudentBasic? student;
const AuthResponse({required this.user, this.student});
factory AuthResponse.fromJson(Map<String, dynamic> json) {
final userMap = SafeConvert.toNullableMap(json['user']) ?? {};
final studentMap = SafeConvert.toNullableMap(json['student']);
return AuthResponse(
user: User.fromJson(userMap),
student: studentMap != null ? StudentBasic.fromJson(studentMap) : null,
);
}
}
class AuthRepository {
final ApiClient _apiClient;
AuthRepository({required ApiClient apiClient}) : _apiClient = apiClient;
Future<AuthResponse> login({
required String email,
required String password,
}) async {
final data = await _apiClient.post(
ApiEndpoints.login,
data: {
'email': email.trim(),
'password': password,
},
);
return AuthResponse.fromJson(SafeConvert.toNullableMap(data) ?? {});
}
Future<void> logout() async {
try {
await _apiClient.post(ApiEndpoints.logout);
} finally {
await _apiClient.clearSession();
}
}
Future<AuthResponse> getMe() async {
final data = await _apiClient.get(ApiEndpoints.me);
return AuthResponse.fromJson(SafeConvert.toNullableMap(data) ?? {});
}
}

View File

@ -0,0 +1,19 @@
import '../core/api/api_client.dart';
import '../core/api/api_endpoints.dart';
import '../core/utils/safe_convert.dart';
import '../models/exam.dart';
class ExamRepository {
final ApiClient _apiClient;
ExamRepository({required ApiClient apiClient}) : _apiClient = apiClient;
Future<List<Exam>> getExams() async {
final data = await _apiClient.get(ApiEndpoints.exams);
final map = SafeConvert.toNullableMap(data) ?? {};
return SafeConvert.toSafeList(
map['exams'],
(item) => Exam.fromJson(SafeConvert.toNullableMap(item) ?? {}),
);
}
}

View File

@ -0,0 +1,19 @@
import '../core/api/api_client.dart';
import '../core/api/api_endpoints.dart';
import '../core/utils/safe_convert.dart';
import '../models/result.dart';
class ResultRepository {
final ApiClient _apiClient;
ResultRepository({required ApiClient apiClient}) : _apiClient = apiClient;
Future<List<ExamResult>> getResults() async {
final data = await _apiClient.get(ApiEndpoints.results);
final map = SafeConvert.toNullableMap(data) ?? {};
return SafeConvert.toSafeList(
map['results'],
(item) => ExamResult.fromJson(SafeConvert.toNullableMap(item) ?? {}),
);
}
}

View File

@ -0,0 +1,21 @@
import '../core/api/api_client.dart';
import '../core/api/api_endpoints.dart';
import '../core/utils/safe_convert.dart';
import '../models/dashboard.dart';
import '../models/student.dart';
class StudentRepository {
final ApiClient _apiClient;
StudentRepository({required ApiClient apiClient}) : _apiClient = apiClient;
Future<StudentProfile> getProfile() async {
final data = await _apiClient.get(ApiEndpoints.profile);
return StudentProfile.fromJson(SafeConvert.toNullableMap(data) ?? {});
}
Future<DashboardData> getDashboard() async {
final data = await _apiClient.get(ApiEndpoints.dashboard);
return DashboardData.fromJson(SafeConvert.toNullableMap(data) ?? {});
}
}

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