diff --git a/.metadata b/.metadata
new file mode 100644
index 00000000..08c24780
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,45 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ - platform: android
+ create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ - platform: ios
+ create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ - platform: linux
+ create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ - platform: macos
+ create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ - platform: web
+ create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ - platform: windows
+ create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/.vscode/mcp.json b/.vscode/mcp.json
new file mode 100644
index 00000000..0a531ebd
--- /dev/null
+++ b/.vscode/mcp.json
@@ -0,0 +1,9 @@
+{
+ "servers": {
+ "supabase": {
+ "type": "http",
+ "url": "https://mcp.supabase.com/mcp?project_ref=wqjebgpbwrfzshaabprh"
+ }
+ },
+ "inputs": []
+}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..2c049e5a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,3 @@
+# tasq
+
+A new Flutter project.
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 00000000..f9b30346
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:flutter_lints/flutter.yaml
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 00000000..be3943c9
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 00000000..d2f5ba6b
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "com.example.tasq"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.example.tasq"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..399f6981
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..c0b90a2e
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/example/tasq/MainActivity.kt b/android/app/src/main/kotlin/com/example/tasq/MainActivity.kt
new file mode 100644
index 00000000..cc94f81f
--- /dev/null
+++ b/android/app/src/main/kotlin/com/example/tasq/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.example.tasq
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 00000000..f74085f3
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 00000000..304732f8
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..0813bfab
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..2501b925
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..31ab5851
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..92c47791
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..904167f5
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 00000000..06952be7
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 00000000..cb1ef880
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 00000000..399f6981
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 00000000..dbee657b
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 00000000..fbee1d8c
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..e4ef43fb
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 00000000..ca7fe065
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")
diff --git a/assets/tasq_ico.png b/assets/tasq_ico.png
new file mode 100644
index 00000000..5efbbdeb
Binary files /dev/null and b/assets/tasq_ico.png differ
diff --git a/assets/tasq_notification.wav b/assets/tasq_notification.wav
new file mode 100644
index 00000000..bd047ac1
Binary files /dev/null and b/assets/tasq_notification.wav differ
diff --git a/ios/.gitignore b/ios/.gitignore
new file mode 100644
index 00000000..7a7f9873
--- /dev/null
+++ b/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 00000000..1dc6cf76
--- /dev/null
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+ MinimumOSVersion
+ 13.0
+
+
diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
new file mode 100644
index 00000000..592ceee8
--- /dev/null
+++ b/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
new file mode 100644
index 00000000..592ceee8
--- /dev/null
+++ b/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..bcacaf8a
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,616 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.tasq;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.tasq.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.tasq.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.tasq.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.tasq;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.tasq;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 00000000..f9b0d7c5
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 00000000..e3773d42
--- /dev/null
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..1d526a16
--- /dev/null
+++ b/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 00000000..f9b0d7c5
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
new file mode 100644
index 00000000..62666446
--- /dev/null
+++ b/ios/Runner/AppDelegate.swift
@@ -0,0 +1,13 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ GeneratedPluginRegistrant.register(with: self)
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..d36b1fab
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "images" : [
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "83.5x83.5",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 00000000..0557e1ab
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 00000000..a3ca0407
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 00000000..5e963b79
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 00000000..df655f7d
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 00000000..5017990b
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 00000000..c7601299
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 00000000..bcabdbcc
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 00000000..5e963b79
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 00000000..73938a0f
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 00000000..46d4ea72
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
new file mode 100644
index 00000000..1e803b69
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
new file mode 100644
index 00000000..79f896d8
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
new file mode 100644
index 00000000..ed012636
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
new file mode 100644
index 00000000..7505e35a
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 00000000..46d4ea72
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 00000000..f855f13a
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
new file mode 100644
index 00000000..0813bfab
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
new file mode 100644
index 00000000..92c47791
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 00000000..8e022e97
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 00000000..79419e76
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 00000000..19a26dbc
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 00000000..0bedcf2f
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 00000000..9da19eac
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 00000000..9da19eac
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 00000000..9da19eac
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 00000000..89c2725b
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 00000000..f2e259c7
--- /dev/null
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 00000000..f3c28516
--- /dev/null
+++ b/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
new file mode 100644
index 00000000..c03a8c3e
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,49 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Tasq
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ tasq
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSupportsIndirectInputEvents
+
+
+
diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 00000000..308a2a56
--- /dev/null
+++ b/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 00000000..86a7c3b1
--- /dev/null
+++ b/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/lib/app.dart b/lib/app.dart
new file mode 100644
index 00000000..b17f1a4a
--- /dev/null
+++ b/lib/app.dart
@@ -0,0 +1,22 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import 'routing/app_router.dart';
+import 'theme/app_theme.dart';
+
+class TasqApp extends ConsumerWidget {
+ const TasqApp({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final router = ref.watch(appRouterProvider);
+
+ return MaterialApp.router(
+ title: 'TasQ',
+ routerConfig: router,
+ theme: AppTheme.light(),
+ darkTheme: AppTheme.dark(),
+ themeMode: ThemeMode.system,
+ );
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 00000000..47b79ec2
--- /dev/null
+++ b/lib/main.dart
@@ -0,0 +1,73 @@
+import 'package:flutter/material.dart';
+import 'package:audioplayers/audioplayers.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+
+import 'app.dart';
+import 'providers/notifications_provider.dart';
+
+Future main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+
+ await dotenv.load(fileName: '.env');
+
+ final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? '';
+ final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? '';
+
+ if (supabaseUrl.isEmpty || supabaseAnonKey.isEmpty) {
+ runApp(const _MissingConfigApp());
+ return;
+ }
+
+ await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);
+
+ runApp(
+ ProviderScope(
+ observers: [NotificationSoundObserver()],
+ child: const TasqApp(),
+ ),
+ );
+}
+
+class NotificationSoundObserver extends ProviderObserver {
+ static final AudioPlayer _player = AudioPlayer();
+
+ @override
+ void didUpdateProvider(
+ ProviderBase provider,
+ Object? previousValue,
+ Object? newValue,
+ ProviderContainer container,
+ ) {
+ if (provider == unreadNotificationsCountProvider) {
+ final prev = previousValue as int?;
+ final next = newValue as int?;
+ if (prev != null && next != null && next > prev) {
+ _player.play(AssetSource('tasq_notification.wav'));
+ }
+ }
+ }
+}
+
+class _MissingConfigApp extends StatelessWidget {
+ const _MissingConfigApp();
+
+ @override
+ Widget build(BuildContext context) {
+ return const MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: Padding(
+ padding: EdgeInsets.all(24),
+ child: Text(
+ 'Missing SUPABASE_URL or SUPABASE_ANON_KEY. '
+ 'Provide them in the .env file.',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/models/notification_item.dart b/lib/models/notification_item.dart
new file mode 100644
index 00000000..e041c864
--- /dev/null
+++ b/lib/models/notification_item.dart
@@ -0,0 +1,41 @@
+class NotificationItem {
+ NotificationItem({
+ required this.id,
+ required this.userId,
+ required this.actorId,
+ required this.ticketId,
+ required this.taskId,
+ required this.messageId,
+ required this.type,
+ required this.createdAt,
+ required this.readAt,
+ });
+
+ final String id;
+ final String userId;
+ final String? actorId;
+ final String? ticketId;
+ final String? taskId;
+ final int? messageId;
+ final String type;
+ final DateTime createdAt;
+ final DateTime? readAt;
+
+ bool get isUnread => readAt == null;
+
+ factory NotificationItem.fromMap(Map map) {
+ return NotificationItem(
+ id: map['id'] as String,
+ userId: map['user_id'] as String,
+ actorId: map['actor_id'] as String?,
+ ticketId: map['ticket_id'] as String?,
+ taskId: map['task_id'] as String?,
+ messageId: map['message_id'] as int?,
+ type: map['type'] as String? ?? 'mention',
+ createdAt: DateTime.parse(map['created_at'] as String),
+ readAt: map['read_at'] == null
+ ? null
+ : DateTime.parse(map['read_at'] as String),
+ );
+ }
+}
diff --git a/lib/models/office.dart b/lib/models/office.dart
new file mode 100644
index 00000000..fc7575fa
--- /dev/null
+++ b/lib/models/office.dart
@@ -0,0 +1,10 @@
+class Office {
+ Office({required this.id, required this.name});
+
+ final String id;
+ final String name;
+
+ factory Office.fromMap(Map map) {
+ return Office(id: map['id'] as String, name: map['name'] as String? ?? '');
+ }
+}
diff --git a/lib/models/profile.dart b/lib/models/profile.dart
new file mode 100644
index 00000000..7af0418a
--- /dev/null
+++ b/lib/models/profile.dart
@@ -0,0 +1,15 @@
+class Profile {
+ Profile({required this.id, required this.role, required this.fullName});
+
+ final String id;
+ final String role;
+ final String fullName;
+
+ factory Profile.fromMap(Map map) {
+ return Profile(
+ id: map['id'] as String,
+ role: map['role'] as String? ?? 'standard',
+ fullName: map['full_name'] as String? ?? '',
+ );
+ }
+}
diff --git a/lib/models/task.dart b/lib/models/task.dart
new file mode 100644
index 00000000..4b6eb621
--- /dev/null
+++ b/lib/models/task.dart
@@ -0,0 +1,50 @@
+class Task {
+ Task({
+ required this.id,
+ required this.ticketId,
+ required this.title,
+ required this.description,
+ required this.officeId,
+ required this.status,
+ required this.priority,
+ required this.queueOrder,
+ required this.createdAt,
+ required this.creatorId,
+ required this.startedAt,
+ required this.completedAt,
+ });
+
+ final String id;
+ final String? ticketId;
+ final String title;
+ final String description;
+ final String? officeId;
+ final String status;
+ final int priority;
+ final int? queueOrder;
+ final DateTime createdAt;
+ final String? creatorId;
+ final DateTime? startedAt;
+ final DateTime? completedAt;
+
+ factory Task.fromMap(Map map) {
+ return Task(
+ id: map['id'] as String,
+ ticketId: map['ticket_id'] as String?,
+ title: map['title'] as String? ?? 'Task',
+ description: map['description'] as String? ?? '',
+ officeId: map['office_id'] as String?,
+ status: map['status'] as String? ?? 'queued',
+ priority: map['priority'] as int? ?? 1,
+ queueOrder: map['queue_order'] as int?,
+ createdAt: DateTime.parse(map['created_at'] as String),
+ creatorId: map['creator_id'] as String?,
+ startedAt: map['started_at'] == null
+ ? null
+ : DateTime.parse(map['started_at'] as String),
+ completedAt: map['completed_at'] == null
+ ? null
+ : DateTime.parse(map['completed_at'] as String),
+ );
+ }
+}
diff --git a/lib/models/task_assignment.dart b/lib/models/task_assignment.dart
new file mode 100644
index 00000000..b04c621f
--- /dev/null
+++ b/lib/models/task_assignment.dart
@@ -0,0 +1,19 @@
+class TaskAssignment {
+ TaskAssignment({
+ required this.taskId,
+ required this.userId,
+ required this.createdAt,
+ });
+
+ final String taskId;
+ final String userId;
+ final DateTime createdAt;
+
+ factory TaskAssignment.fromMap(Map map) {
+ return TaskAssignment(
+ taskId: map['task_id'] as String,
+ userId: map['user_id'] as String,
+ createdAt: DateTime.parse(map['created_at'] as String),
+ );
+ }
+}
diff --git a/lib/models/ticket.dart b/lib/models/ticket.dart
new file mode 100644
index 00000000..251792ce
--- /dev/null
+++ b/lib/models/ticket.dart
@@ -0,0 +1,46 @@
+class Ticket {
+ Ticket({
+ required this.id,
+ required this.subject,
+ required this.description,
+ required this.officeId,
+ required this.status,
+ required this.createdAt,
+ required this.creatorId,
+ required this.respondedAt,
+ required this.promotedAt,
+ required this.closedAt,
+ });
+
+ final String id;
+ final String subject;
+ final String description;
+ final String officeId;
+ final String status;
+ final DateTime createdAt;
+ final String? creatorId;
+ final DateTime? respondedAt;
+ final DateTime? promotedAt;
+ final DateTime? closedAt;
+
+ factory Ticket.fromMap(Map map) {
+ return Ticket(
+ id: map['id'] as String,
+ subject: map['subject'] as String? ?? '',
+ description: map['description'] as String? ?? '',
+ officeId: map['office_id'] as String? ?? '',
+ status: map['status'] as String? ?? 'pending',
+ createdAt: DateTime.parse(map['created_at'] as String),
+ creatorId: map['creator_id'] as String?,
+ respondedAt: map['responded_at'] == null
+ ? null
+ : DateTime.parse(map['responded_at'] as String),
+ promotedAt: map['promoted_at'] == null
+ ? null
+ : DateTime.parse(map['promoted_at'] as String),
+ closedAt: map['closed_at'] == null
+ ? null
+ : DateTime.parse(map['closed_at'] as String),
+ );
+ }
+}
diff --git a/lib/models/ticket_message.dart b/lib/models/ticket_message.dart
new file mode 100644
index 00000000..3e9aadb8
--- /dev/null
+++ b/lib/models/ticket_message.dart
@@ -0,0 +1,28 @@
+class TicketMessage {
+ TicketMessage({
+ required this.id,
+ required this.ticketId,
+ required this.taskId,
+ required this.senderId,
+ required this.content,
+ required this.createdAt,
+ });
+
+ final int id;
+ final String? ticketId;
+ final String? taskId;
+ final String? senderId;
+ final String content;
+ final DateTime createdAt;
+
+ factory TicketMessage.fromMap(Map map) {
+ return TicketMessage(
+ id: map['id'] as int,
+ ticketId: map['ticket_id'] as String?,
+ taskId: map['task_id'] as String?,
+ senderId: map['sender_id'] as String?,
+ content: map['content'] as String? ?? '',
+ createdAt: DateTime.parse(map['created_at'] as String),
+ );
+ }
+}
diff --git a/lib/models/user_office.dart b/lib/models/user_office.dart
new file mode 100644
index 00000000..1e8a35fb
--- /dev/null
+++ b/lib/models/user_office.dart
@@ -0,0 +1,13 @@
+class UserOffice {
+ UserOffice({required this.userId, required this.officeId});
+
+ final String userId;
+ final String officeId;
+
+ factory UserOffice.fromMap(Map map) {
+ return UserOffice(
+ userId: map['user_id'] as String,
+ officeId: map['office_id'] as String,
+ );
+ }
+}
diff --git a/lib/providers/admin_user_provider.dart b/lib/providers/admin_user_provider.dart
new file mode 100644
index 00000000..ab43dc18
--- /dev/null
+++ b/lib/providers/admin_user_provider.dart
@@ -0,0 +1,105 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import 'supabase_provider.dart';
+
+final adminUserControllerProvider = Provider((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return AdminUserController(client);
+});
+
+class AdminUserStatus {
+ AdminUserStatus({required this.email, required this.bannedUntil});
+
+ final String? email;
+ final DateTime? bannedUntil;
+
+ bool get isLocked {
+ if (bannedUntil == null) return false;
+ return bannedUntil!.isAfter(DateTime.now().toUtc());
+ }
+}
+
+class AdminUserController {
+ AdminUserController(this._client);
+
+ final SupabaseClient _client;
+
+ Future updateProfile({
+ required String userId,
+ required String fullName,
+ required String role,
+ }) async {
+ await _client
+ .from('profiles')
+ .update({'full_name': fullName, 'role': role})
+ .eq('id', userId);
+ }
+
+ Future updateRole({
+ required String userId,
+ required String role,
+ }) async {
+ await _client.from('profiles').update({'role': role}).eq('id', userId);
+ }
+
+ Future setPassword({
+ required String userId,
+ required String password,
+ }) async {
+ await _invokeAdminFunction(
+ action: 'set_password',
+ payload: {'userId': userId, 'password': password},
+ );
+ }
+
+ Future setLock({required String userId, required bool locked}) async {
+ await _invokeAdminFunction(
+ action: 'set_lock',
+ payload: {'userId': userId, 'locked': locked},
+ );
+ }
+
+ Future fetchStatus(String userId) async {
+ final data = await _invokeAdminFunction(
+ action: 'get_user',
+ payload: {'userId': userId},
+ );
+ final user = (data as Map)['user'] as Map;
+ final bannedUntilRaw = user['banned_until'] as String?;
+ return AdminUserStatus(
+ email: user['email'] as String?,
+ bannedUntil: bannedUntilRaw == null
+ ? null
+ : DateTime.tryParse(bannedUntilRaw),
+ );
+ }
+
+ Future _invokeAdminFunction({
+ required String action,
+ required Map payload,
+ }) async {
+ final response = await _client.functions.invoke(
+ 'admin_user_management',
+ body: {'action': action, ...payload},
+ );
+ if (response.status != 200) {
+ throw Exception(_extractErrorMessage(response.data));
+ }
+ return response.data;
+ }
+
+ String _extractErrorMessage(dynamic data) {
+ if (data is Map) {
+ final error = data['error'];
+ if (error is String && error.trim().isNotEmpty) {
+ return error;
+ }
+ final message = data['message'];
+ if (message is String && message.trim().isNotEmpty) {
+ return message;
+ }
+ }
+ return 'Admin request failed.';
+ }
+}
diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart
new file mode 100644
index 00000000..be0016f0
--- /dev/null
+++ b/lib/providers/auth_provider.dart
@@ -0,0 +1,69 @@
+import 'dart:async';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import 'supabase_provider.dart';
+
+final authStateChangesProvider = StreamProvider((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return client.auth.onAuthStateChange;
+});
+
+final sessionProvider = Provider((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return client.auth.currentSession;
+});
+
+final authControllerProvider = Provider((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return AuthController(client);
+});
+
+class AuthController {
+ AuthController(this._client);
+
+ final SupabaseClient _client;
+
+ Future signInWithPassword({
+ required String email,
+ required String password,
+ }) {
+ return _client.auth.signInWithPassword(email: email, password: password);
+ }
+
+ Future signUp({
+ required String email,
+ required String password,
+ String? fullName,
+ List? officeIds,
+ }) {
+ return _client.auth.signUp(
+ email: email,
+ password: password,
+ data: {
+ if (fullName != null && fullName.trim().isNotEmpty)
+ 'full_name': fullName.trim(),
+ if (officeIds != null && officeIds.isNotEmpty) 'office_ids': officeIds,
+ },
+ );
+ }
+
+ Future signInWithGoogle({String? redirectTo}) {
+ return _client.auth.signInWithOAuth(
+ OAuthProvider.google,
+ redirectTo: redirectTo,
+ );
+ }
+
+ Future signInWithMeta({String? redirectTo}) {
+ return _client.auth.signInWithOAuth(
+ OAuthProvider.facebook,
+ redirectTo: redirectTo,
+ );
+ }
+
+ Future signOut() {
+ return _client.auth.signOut();
+ }
+}
diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart
new file mode 100644
index 00000000..42014348
--- /dev/null
+++ b/lib/providers/notifications_provider.dart
@@ -0,0 +1,97 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import '../models/notification_item.dart';
+import 'profile_provider.dart';
+import 'supabase_provider.dart';
+
+final notificationsProvider = StreamProvider>((ref) {
+ final userId = ref.watch(currentUserIdProvider);
+ if (userId == null) {
+ return const Stream.empty();
+ }
+ final client = ref.watch(supabaseClientProvider);
+ return client
+ .from('notifications')
+ .stream(primaryKey: ['id'])
+ .eq('user_id', userId)
+ .order('created_at', ascending: false)
+ .map((rows) => rows.map(NotificationItem.fromMap).toList());
+});
+
+final unreadNotificationsCountProvider = Provider((ref) {
+ final notificationsAsync = ref.watch(notificationsProvider);
+ return notificationsAsync.maybeWhen(
+ data: (items) => items.where((item) => item.isUnread).length,
+ orElse: () => 0,
+ );
+});
+
+final notificationsControllerProvider = Provider((
+ ref,
+) {
+ final client = ref.watch(supabaseClientProvider);
+ return NotificationsController(client);
+});
+
+class NotificationsController {
+ NotificationsController(this._client);
+
+ final SupabaseClient _client;
+
+ Future createMentionNotifications({
+ required List userIds,
+ required String actorId,
+ required int messageId,
+ String? ticketId,
+ String? taskId,
+ }) async {
+ if (userIds.isEmpty) return;
+ if ((ticketId == null || ticketId.isEmpty) &&
+ (taskId == null || taskId.isEmpty)) {
+ return;
+ }
+ final rows = userIds
+ .map(
+ (userId) => {
+ 'user_id': userId,
+ 'actor_id': actorId,
+ 'ticket_id': ticketId,
+ 'task_id': taskId,
+ 'message_id': messageId,
+ 'type': 'mention',
+ },
+ )
+ .toList();
+ await _client.from('notifications').insert(rows);
+ }
+
+ Future markRead(String id) async {
+ await _client
+ .from('notifications')
+ .update({'read_at': DateTime.now().toUtc().toIso8601String()})
+ .eq('id', id);
+ }
+
+ Future markReadForTicket(String ticketId) async {
+ final userId = _client.auth.currentUser?.id;
+ if (userId == null) return;
+ await _client
+ .from('notifications')
+ .update({'read_at': DateTime.now().toUtc().toIso8601String()})
+ .eq('ticket_id', ticketId)
+ .eq('user_id', userId)
+ .filter('read_at', 'is', null);
+ }
+
+ Future markReadForTask(String taskId) async {
+ final userId = _client.auth.currentUser?.id;
+ if (userId == null) return;
+ await _client
+ .from('notifications')
+ .update({'read_at': DateTime.now().toUtc().toIso8601String()})
+ .eq('task_id', taskId)
+ .eq('user_id', userId)
+ .filter('read_at', 'is', null);
+ }
+}
diff --git a/lib/providers/profile_provider.dart b/lib/providers/profile_provider.dart
new file mode 100644
index 00000000..a110e175
--- /dev/null
+++ b/lib/providers/profile_provider.dart
@@ -0,0 +1,45 @@
+import 'dart:async';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../models/profile.dart';
+import 'auth_provider.dart';
+import 'supabase_provider.dart';
+
+final currentUserIdProvider = Provider((ref) {
+ final authState = ref.watch(authStateChangesProvider);
+ return authState.maybeWhen(
+ data: (state) => state.session?.user.id,
+ orElse: () => ref.watch(sessionProvider)?.user.id,
+ );
+});
+
+final currentProfileProvider = StreamProvider((ref) {
+ final userId = ref.watch(currentUserIdProvider);
+ if (userId == null) {
+ return const Stream.empty();
+ }
+ final client = ref.watch(supabaseClientProvider);
+ return client
+ .from('profiles')
+ .stream(primaryKey: ['id'])
+ .eq('id', userId)
+ .map((rows) => rows.isEmpty ? null : Profile.fromMap(rows.first));
+});
+
+final profilesProvider = StreamProvider>((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return client
+ .from('profiles')
+ .stream(primaryKey: ['id'])
+ .order('full_name')
+ .map((rows) => rows.map(Profile.fromMap).toList());
+});
+
+final isAdminProvider = Provider((ref) {
+ final profileAsync = ref.watch(currentProfileProvider);
+ return profileAsync.maybeWhen(
+ data: (profile) => profile?.role == 'admin',
+ orElse: () => false,
+ );
+});
diff --git a/lib/providers/supabase_provider.dart b/lib/providers/supabase_provider.dart
new file mode 100644
index 00000000..5729a60c
--- /dev/null
+++ b/lib/providers/supabase_provider.dart
@@ -0,0 +1,6 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+final supabaseClientProvider = Provider((ref) {
+ return Supabase.instance.client;
+});
diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart
new file mode 100644
index 00000000..17e29c2d
--- /dev/null
+++ b/lib/providers/tasks_provider.dart
@@ -0,0 +1,235 @@
+import 'dart:async';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+import '../models/task.dart';
+import '../models/task_assignment.dart';
+import 'profile_provider.dart';
+import 'supabase_provider.dart';
+import 'tickets_provider.dart';
+import 'user_offices_provider.dart';
+
+final tasksProvider = StreamProvider>((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ final profileAsync = ref.watch(currentProfileProvider);
+ final ticketsAsync = ref.watch(ticketsProvider);
+ final assignmentsAsync = ref.watch(userOfficesProvider);
+
+ final profile = profileAsync.valueOrNull;
+ if (profile == null) {
+ return Stream.value(const []);
+ }
+
+ final isGlobal =
+ profile.role == 'admin' ||
+ profile.role == 'dispatcher' ||
+ profile.role == 'it_staff';
+
+ if (isGlobal) {
+ return client
+ .from('tasks')
+ .stream(primaryKey: ['id'])
+ .order('queue_order', ascending: true)
+ .order('created_at')
+ .map((rows) => rows.map(Task.fromMap).toList());
+ }
+
+ final allowedTicketIds =
+ ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
+ [];
+ final officeIds =
+ assignmentsAsync.valueOrNull
+ ?.where((assignment) => assignment.userId == profile.id)
+ .map((assignment) => assignment.officeId)
+ .toSet()
+ .toList() ??
+ [];
+
+ if (allowedTicketIds.isEmpty && officeIds.isEmpty) {
+ return Stream.value(const []);
+ }
+
+ return client
+ .from('tasks')
+ .stream(primaryKey: ['id'])
+ .order('queue_order', ascending: true)
+ .order('created_at')
+ .map(
+ (rows) => rows.map(Task.fromMap).where((task) {
+ final matchesTicket =
+ task.ticketId != null && allowedTicketIds.contains(task.ticketId);
+ final matchesOffice =
+ task.officeId != null && officeIds.contains(task.officeId);
+ return matchesTicket || matchesOffice;
+ }).toList(),
+ );
+});
+
+final taskAssignmentsProvider = StreamProvider>((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return client
+ .from('task_assignments')
+ .stream(primaryKey: ['task_id', 'user_id'])
+ .map((rows) => rows.map(TaskAssignment.fromMap).toList());
+});
+
+final taskAssignmentsControllerProvider = Provider((
+ ref,
+) {
+ final client = ref.watch(supabaseClientProvider);
+ return TaskAssignmentsController(client);
+});
+
+final tasksControllerProvider = Provider((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return TasksController(client);
+});
+
+class TasksController {
+ TasksController(this._client);
+
+ final SupabaseClient _client;
+
+ Future updateTaskStatus({
+ required String taskId,
+ required String status,
+ }) async {
+ await _client.from('tasks').update({'status': status}).eq('id', taskId);
+ }
+
+ Future createTask({
+ required String title,
+ required String description,
+ required String officeId,
+ }) async {
+ final actorId = _client.auth.currentUser?.id;
+ final data = await _client
+ .from('tasks')
+ .insert({
+ 'title': title,
+ 'description': description,
+ 'office_id': officeId,
+ })
+ .select('id')
+ .single();
+ final taskId = data['id'] as String?;
+ if (taskId == null) return;
+ unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
+ }
+
+ Future _notifyCreated({
+ required String taskId,
+ required String? actorId,
+ }) async {
+ try {
+ final recipients = await _fetchRoleUserIds(
+ roles: const ['dispatcher', 'it_staff'],
+ excludeUserId: actorId,
+ );
+ if (recipients.isEmpty) return;
+ final rows = recipients
+ .map(
+ (userId) => {
+ 'user_id': userId,
+ 'actor_id': actorId,
+ 'task_id': taskId,
+ 'type': 'created',
+ },
+ )
+ .toList();
+ await _client.from('notifications').insert(rows);
+ } catch (_) {
+ return;
+ }
+ }
+
+ Future> _fetchRoleUserIds({
+ required List roles,
+ required String? excludeUserId,
+ }) async {
+ try {
+ final data = await _client
+ .from('profiles')
+ .select('id, role')
+ .inFilter('role', roles);
+ final rows = data as List;
+ final ids = rows
+ .map((row) => row['id'] as String?)
+ .whereType()
+ .where((id) => id.isNotEmpty && id != excludeUserId)
+ .toList();
+ return ids;
+ } catch (_) {
+ return [];
+ }
+ }
+}
+
+class TaskAssignmentsController {
+ TaskAssignmentsController(this._client);
+
+ final SupabaseClient _client;
+
+ Future replaceAssignments({
+ required String taskId,
+ required String? ticketId,
+ required List newUserIds,
+ required List currentUserIds,
+ }) async {
+ final nextIds = newUserIds.toSet();
+ final currentIds = currentUserIds.toSet();
+ final toAdd = nextIds.difference(currentIds).toList();
+ final toRemove = currentIds.difference(nextIds).toList();
+
+ if (toAdd.isNotEmpty) {
+ final rows = toAdd
+ .map((userId) => {'task_id': taskId, 'user_id': userId})
+ .toList();
+ await _client.from('task_assignments').insert(rows);
+ await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: toAdd);
+ }
+ if (toRemove.isNotEmpty) {
+ await _client
+ .from('task_assignments')
+ .delete()
+ .eq('task_id', taskId)
+ .inFilter('user_id', toRemove);
+ }
+ }
+
+ Future _notifyAssigned({
+ required String taskId,
+ required String? ticketId,
+ required List userIds,
+ }) async {
+ if (userIds.isEmpty) return;
+ try {
+ final actorId = _client.auth.currentUser?.id;
+ final rows = userIds
+ .map(
+ (userId) => {
+ 'user_id': userId,
+ 'actor_id': actorId,
+ 'task_id': taskId,
+ 'ticket_id': ticketId,
+ 'type': 'assignment',
+ },
+ )
+ .toList();
+ await _client.from('notifications').insert(rows);
+ } catch (_) {
+ return;
+ }
+ }
+
+ Future removeAssignment({
+ required String taskId,
+ required String userId,
+ }) async {
+ await _client
+ .from('task_assignments')
+ .delete()
+ .eq('task_id', taskId)
+ .eq('user_id', userId);
+ }
+}
diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart
new file mode 100644
index 00000000..111067e2
--- /dev/null
+++ b/lib/providers/tickets_provider.dart
@@ -0,0 +1,248 @@
+import 'dart:async';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import '../models/office.dart';
+import '../models/ticket.dart';
+import '../models/ticket_message.dart';
+import 'profile_provider.dart';
+import 'supabase_provider.dart';
+import 'user_offices_provider.dart';
+
+final officesProvider = StreamProvider>((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return client
+ .from('offices')
+ .stream(primaryKey: ['id'])
+ .order('name')
+ .map((rows) => rows.map(Office.fromMap).toList());
+});
+
+final officesOnceProvider = FutureProvider>((ref) async {
+ final client = ref.watch(supabaseClientProvider);
+ final rows = await client.from('offices').select().order('name');
+ return (rows as List)
+ .map((row) => Office.fromMap(row as Map))
+ .toList();
+});
+
+final officesControllerProvider = Provider((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return OfficesController(client);
+});
+
+final ticketsProvider = StreamProvider>((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ final profileAsync = ref.watch(currentProfileProvider);
+ final assignmentsAsync = ref.watch(userOfficesProvider);
+
+ final profile = profileAsync.valueOrNull;
+ if (profile == null) {
+ return Stream.value(const []);
+ }
+
+ final isGlobal =
+ profile.role == 'admin' ||
+ profile.role == 'dispatcher' ||
+ profile.role == 'it_staff';
+
+ if (isGlobal) {
+ return client
+ .from('tickets')
+ .stream(primaryKey: ['id'])
+ .order('created_at', ascending: false)
+ .map((rows) => rows.map(Ticket.fromMap).toList());
+ }
+
+ final officeIds =
+ assignmentsAsync.valueOrNull
+ ?.where((assignment) => assignment.userId == profile.id)
+ .map((assignment) => assignment.officeId)
+ .toSet()
+ .toList() ??
+ [];
+ if (officeIds.isEmpty) {
+ return Stream.value(const []);
+ }
+
+ return client
+ .from('tickets')
+ .stream(primaryKey: ['id'])
+ .inFilter('office_id', officeIds)
+ .order('created_at', ascending: false)
+ .map((rows) => rows.map(Ticket.fromMap).toList());
+});
+
+final ticketMessagesProvider =
+ StreamProvider.family, String>((ref, ticketId) {
+ final client = ref.watch(supabaseClientProvider);
+ return client
+ .from('ticket_messages')
+ .stream(primaryKey: ['id'])
+ .eq('ticket_id', ticketId)
+ .order('created_at', ascending: false)
+ .map((rows) => rows.map(TicketMessage.fromMap).toList());
+ });
+
+final ticketMessagesAllProvider = StreamProvider>((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return client
+ .from('ticket_messages')
+ .stream(primaryKey: ['id'])
+ .order('created_at', ascending: false)
+ .map((rows) => rows.map(TicketMessage.fromMap).toList());
+});
+
+final taskMessagesProvider = StreamProvider.family, String>(
+ (ref, taskId) {
+ final client = ref.watch(supabaseClientProvider);
+ return client
+ .from('ticket_messages')
+ .stream(primaryKey: ['id'])
+ .eq('task_id', taskId)
+ .order('created_at', ascending: false)
+ .map((rows) => rows.map(TicketMessage.fromMap).toList());
+ },
+);
+
+final ticketsControllerProvider = Provider((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return TicketsController(client);
+});
+
+class TicketsController {
+ TicketsController(this._client);
+
+ final SupabaseClient _client;
+
+ Future createTicket({
+ required String subject,
+ required String description,
+ required String officeId,
+ }) async {
+ final actorId = _client.auth.currentUser?.id;
+ final data = await _client
+ .from('tickets')
+ .insert({
+ 'subject': subject,
+ 'description': description,
+ 'office_id': officeId,
+ 'creator_id': _client.auth.currentUser?.id,
+ })
+ .select('id')
+ .single();
+ final ticketId = data['id'] as String?;
+ if (ticketId == null) return;
+ unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
+ }
+
+ Future _notifyCreated({
+ required String ticketId,
+ required String? actorId,
+ }) async {
+ try {
+ final recipients = await _fetchRoleUserIds(
+ roles: const ['dispatcher', 'it_staff'],
+ excludeUserId: actorId,
+ );
+ if (recipients.isEmpty) return;
+ final rows = recipients
+ .map(
+ (userId) => {
+ 'user_id': userId,
+ 'actor_id': actorId,
+ 'ticket_id': ticketId,
+ 'type': 'created',
+ },
+ )
+ .toList();
+ await _client.from('notifications').insert(rows);
+ } catch (_) {
+ return;
+ }
+ }
+
+ Future> _fetchRoleUserIds({
+ required List roles,
+ required String? excludeUserId,
+ }) async {
+ try {
+ final data = await _client
+ .from('profiles')
+ .select('id, role')
+ .inFilter('role', roles);
+ final rows = data as List;
+ final ids = rows
+ .map((row) => row['id'] as String?)
+ .whereType()
+ .where((id) => id.isNotEmpty && id != excludeUserId)
+ .toList();
+ return ids;
+ } catch (_) {
+ return [];
+ }
+ }
+
+ Future sendTicketMessage({
+ required String ticketId,
+ required String content,
+ }) async {
+ final data = await _client
+ .from('ticket_messages')
+ .insert({
+ 'ticket_id': ticketId,
+ 'content': content,
+ 'sender_id': _client.auth.currentUser?.id,
+ })
+ .select()
+ .single();
+ return TicketMessage.fromMap(data);
+ }
+
+ Future sendTaskMessage({
+ required String taskId,
+ required String? ticketId,
+ required String content,
+ }) async {
+ final payload = {
+ 'task_id': taskId,
+ 'content': content,
+ 'sender_id': _client.auth.currentUser?.id,
+ };
+ if (ticketId != null) {
+ payload['ticket_id'] = ticketId;
+ }
+ final data = await _client
+ .from('ticket_messages')
+ .insert(payload)
+ .select()
+ .single();
+ return TicketMessage.fromMap(data);
+ }
+
+ Future updateTicketStatus({
+ required String ticketId,
+ required String status,
+ }) async {
+ await _client.from('tickets').update({'status': status}).eq('id', ticketId);
+ }
+}
+
+class OfficesController {
+ OfficesController(this._client);
+
+ final SupabaseClient _client;
+
+ Future createOffice({required String name}) async {
+ await _client.from('offices').insert({'name': name});
+ }
+
+ Future updateOffice({required String id, required String name}) async {
+ await _client.from('offices').update({'name': name}).eq('id', id);
+ }
+
+ Future deleteOffice({required String id}) async {
+ await _client.from('offices').delete().eq('id', id);
+ }
+}
diff --git a/lib/providers/typing_provider.dart b/lib/providers/typing_provider.dart
new file mode 100644
index 00000000..0d7993fa
--- /dev/null
+++ b/lib/providers/typing_provider.dart
@@ -0,0 +1,152 @@
+import 'dart:async';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import 'supabase_provider.dart';
+
+class TypingIndicatorState {
+ const TypingIndicatorState({
+ required this.userIds,
+ required this.channelStatus,
+ required this.lastPayload,
+ });
+
+ final Set userIds;
+ final String channelStatus;
+ final Map lastPayload;
+
+ TypingIndicatorState copyWith({
+ Set? userIds,
+ String? channelStatus,
+ Map? lastPayload,
+ }) {
+ return TypingIndicatorState(
+ userIds: userIds ?? this.userIds,
+ channelStatus: channelStatus ?? this.channelStatus,
+ lastPayload: lastPayload ?? this.lastPayload,
+ );
+ }
+}
+
+final typingIndicatorProvider = StateNotifierProvider.autoDispose
+ .family((
+ ref,
+ ticketId,
+ ) {
+ final client = ref.watch(supabaseClientProvider);
+ final controller = TypingIndicatorController(client, ticketId);
+ ref.onDispose(controller.dispose);
+ return controller;
+ });
+
+class TypingIndicatorController extends StateNotifier {
+ TypingIndicatorController(this._client, this._ticketId)
+ : super(
+ const TypingIndicatorState(
+ userIds: {},
+ channelStatus: 'init',
+ lastPayload: {},
+ ),
+ ) {
+ _initChannel();
+ }
+
+ final SupabaseClient _client;
+ final String _ticketId;
+ RealtimeChannel? _channel;
+ Timer? _typingTimer;
+ final Map _remoteTimeouts = {};
+
+ void _initChannel() {
+ final channel = _client.channel('typing:$_ticketId');
+ channel.onBroadcast(
+ event: 'typing',
+ callback: (payload) {
+ final Map data = _extractPayload(payload);
+ final userId = data['user_id'] as String?;
+ final rawType = data['type']?.toString();
+ final currentUserId = _client.auth.currentUser?.id;
+ state = state.copyWith(lastPayload: data);
+ if (userId == null || userId == currentUserId) {
+ return;
+ }
+ if (rawType == 'stop') {
+ _clearRemoteTyping(userId);
+ return;
+ }
+ _markRemoteTyping(userId);
+ },
+ );
+ channel.subscribe((status, error) {
+ state = state.copyWith(channelStatus: status.name);
+ });
+ _channel = channel;
+ }
+
+ Map _extractPayload(dynamic payload) {
+ if (payload is Map) {
+ final inner = payload['payload'];
+ if (inner is Map) {
+ return inner;
+ }
+ return payload;
+ }
+ final dynamic inner = payload.payload;
+ if (inner is Map) {
+ return inner;
+ }
+ return {};
+ }
+
+ void userTyping() {
+ if (_client.auth.currentUser?.id == null) return;
+ _sendTypingEvent('start');
+ _typingTimer?.cancel();
+ _typingTimer = Timer(const Duration(milliseconds: 150), () {
+ _sendTypingEvent('stop');
+ });
+ }
+
+ void stopTyping() {
+ _typingTimer?.cancel();
+ _sendTypingEvent('stop');
+ }
+
+ void _markRemoteTyping(String userId) {
+ final updated = {...state.userIds, userId};
+ state = state.copyWith(userIds: updated);
+ _remoteTimeouts[userId]?.cancel();
+ _remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
+ _clearRemoteTyping(userId);
+ });
+ }
+
+ void _clearRemoteTyping(String userId) {
+ final updated = {...state.userIds}..remove(userId);
+ state = state.copyWith(userIds: updated);
+ _remoteTimeouts[userId]?.cancel();
+ _remoteTimeouts.remove(userId);
+ }
+
+ void _sendTypingEvent(String type) {
+ final userId = _client.auth.currentUser?.id;
+ if (userId == null || _channel == null) return;
+ _channel!.sendBroadcastMessage(
+ event: 'typing',
+ payload: {'user_id': userId, 'type': type},
+ );
+ }
+
+ @override
+ void dispose() {
+ stopTyping();
+ _typingTimer?.cancel();
+ for (final timer in _remoteTimeouts.values) {
+ timer.cancel();
+ }
+ _remoteTimeouts.clear();
+ _channel?.unsubscribe();
+ super.dispose();
+ }
+}
diff --git a/lib/providers/user_offices_provider.dart b/lib/providers/user_offices_provider.dart
new file mode 100644
index 00000000..6742bb66
--- /dev/null
+++ b/lib/providers/user_offices_provider.dart
@@ -0,0 +1,46 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import '../models/user_office.dart';
+import 'supabase_provider.dart';
+
+final userOfficesProvider = StreamProvider>((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return client
+ .from('user_offices')
+ .stream(primaryKey: ['user_id', 'office_id'])
+ .order('created_at')
+ .map((rows) => rows.map(UserOffice.fromMap).toList());
+});
+
+final userOfficesControllerProvider = Provider((ref) {
+ final client = ref.watch(supabaseClientProvider);
+ return UserOfficesController(client);
+});
+
+class UserOfficesController {
+ UserOfficesController(this._client);
+
+ final SupabaseClient _client;
+
+ Future assignUserOffice({
+ required String userId,
+ required String officeId,
+ }) async {
+ await _client.from('user_offices').insert({
+ 'user_id': userId,
+ 'office_id': officeId,
+ });
+ }
+
+ Future removeUserOffice({
+ required String userId,
+ required String officeId,
+ }) async {
+ await _client
+ .from('user_offices')
+ .delete()
+ .eq('user_id', userId)
+ .eq('office_id', officeId);
+ }
+}
diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart
new file mode 100644
index 00000000..ea346904
--- /dev/null
+++ b/lib/routing/app_router.dart
@@ -0,0 +1,160 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../providers/auth_provider.dart';
+import '../providers/profile_provider.dart';
+import '../screens/auth/login_screen.dart';
+import '../screens/auth/signup_screen.dart';
+import '../screens/admin/offices_screen.dart';
+import '../screens/admin/user_management_screen.dart';
+import '../screens/dashboard/dashboard_screen.dart';
+import '../screens/notifications/notifications_screen.dart';
+import '../screens/shared/under_development_screen.dart';
+import '../screens/tasks/task_detail_screen.dart';
+import '../screens/tasks/tasks_list_screen.dart';
+import '../screens/tickets/ticket_detail_screen.dart';
+import '../screens/tickets/tickets_list_screen.dart';
+import '../widgets/app_shell.dart';
+
+final appRouterProvider = Provider((ref) {
+ final notifier = RouterNotifier(ref);
+ ref.onDispose(notifier.dispose);
+
+ return GoRouter(
+ initialLocation: '/dashboard',
+ refreshListenable: notifier,
+ redirect: (context, state) {
+ final authState = ref.read(authStateChangesProvider);
+ final session = authState.maybeWhen(
+ data: (state) => state.session,
+ orElse: () => ref.read(sessionProvider),
+ );
+ final isAuthRoute =
+ state.fullPath == '/login' || state.fullPath == '/signup';
+ final isSignedIn = session != null;
+ final profileAsync = ref.read(currentProfileProvider);
+ final isAdminRoute = state.matchedLocation.startsWith('/settings');
+ final isAdmin = profileAsync.maybeWhen(
+ data: (profile) => profile?.role == 'admin',
+ orElse: () => false,
+ );
+
+ if (!isSignedIn && !isAuthRoute) {
+ return '/login';
+ }
+ if (isSignedIn && isAuthRoute) {
+ return '/dashboard';
+ }
+ if (isAdminRoute && !isAdmin) {
+ return '/tickets';
+ }
+ return null;
+ },
+ routes: [
+ GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
+ GoRoute(
+ path: '/signup',
+ builder: (context, state) => const SignUpScreen(),
+ ),
+ ShellRoute(
+ builder: (context, state, child) => AppScaffold(child: child),
+ routes: [
+ GoRoute(
+ path: '/dashboard',
+ builder: (context, state) => const DashboardScreen(),
+ ),
+ GoRoute(
+ path: '/tickets',
+ builder: (context, state) => const TicketsListScreen(),
+ routes: [
+ GoRoute(
+ path: ':id',
+ builder: (context, state) => TicketDetailScreen(
+ ticketId: state.pathParameters['id'] ?? '',
+ ),
+ ),
+ ],
+ ),
+ GoRoute(
+ path: '/tasks',
+ builder: (context, state) => const TasksListScreen(),
+ routes: [
+ GoRoute(
+ path: ':id',
+ builder: (context, state) =>
+ TaskDetailScreen(taskId: state.pathParameters['id'] ?? ''),
+ ),
+ ],
+ ),
+ GoRoute(
+ path: '/events',
+ builder: (context, state) => const UnderDevelopmentScreen(
+ title: 'Events',
+ subtitle: 'Event monitoring is under development.',
+ icon: Icons.event,
+ ),
+ ),
+ GoRoute(
+ path: '/announcements',
+ builder: (context, state) => const UnderDevelopmentScreen(
+ title: 'Announcement',
+ subtitle: 'Operational broadcasts are coming soon.',
+ icon: Icons.campaign,
+ ),
+ ),
+ GoRoute(
+ path: '/workforce',
+ builder: (context, state) => const UnderDevelopmentScreen(
+ title: 'Workforce',
+ subtitle: 'Workforce management is in progress.',
+ icon: Icons.groups,
+ ),
+ ),
+ GoRoute(
+ path: '/reports',
+ builder: (context, state) => const UnderDevelopmentScreen(
+ title: 'Reports',
+ subtitle: 'Reporting automation is under development.',
+ icon: Icons.analytics,
+ ),
+ ),
+ GoRoute(
+ path: '/settings/users',
+ builder: (context, state) => const UserManagementScreen(),
+ ),
+ GoRoute(
+ path: '/settings/offices',
+ builder: (context, state) => const OfficesScreen(),
+ ),
+ GoRoute(
+ path: '/notifications',
+ builder: (context, state) => const NotificationsScreen(),
+ ),
+ ],
+ ),
+ ],
+ );
+});
+
+class RouterNotifier extends ChangeNotifier {
+ RouterNotifier(this.ref) {
+ _authSub = ref.listen(authStateChangesProvider, (previous, next) {
+ notifyListeners();
+ });
+ _profileSub = ref.listen(currentProfileProvider, (previous, next) {
+ notifyListeners();
+ });
+ }
+
+ final Ref ref;
+ late final ProviderSubscription _authSub;
+ late final ProviderSubscription _profileSub;
+
+ @override
+ void dispose() {
+ _authSub.close();
+ _profileSub.close();
+ super.dispose();
+ }
+}
diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart
new file mode 100644
index 00000000..2c7f48a3
--- /dev/null
+++ b/lib/screens/admin/offices_screen.dart
@@ -0,0 +1,185 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../models/office.dart';
+import '../../providers/profile_provider.dart';
+import '../../providers/tickets_provider.dart';
+import '../../widgets/responsive_body.dart';
+
+class OfficesScreen extends ConsumerWidget {
+ const OfficesScreen({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final isAdmin = ref.watch(isAdminProvider);
+ final officesAsync = ref.watch(officesProvider);
+
+ return Scaffold(
+ body: ResponsiveBody(
+ maxWidth: 800,
+ child: !isAdmin
+ ? const Center(child: Text('Admin access required.'))
+ : officesAsync.when(
+ data: (offices) {
+ if (offices.isEmpty) {
+ return const Center(child: Text('No offices found.'));
+ }
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(top: 16, bottom: 8),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ 'Office Management',
+ style: Theme.of(context).textTheme.titleLarge
+ ?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ ),
+ TextButton.icon(
+ onPressed: () => context.go('/settings/users'),
+ icon: const Icon(Icons.group),
+ label: const Text('User access'),
+ ),
+ ],
+ ),
+ ),
+ Expanded(
+ child: ListView.separated(
+ padding: const EdgeInsets.only(bottom: 24),
+ itemCount: offices.length,
+ separatorBuilder: (_, index) =>
+ const SizedBox(height: 12),
+ itemBuilder: (context, index) {
+ final office = offices[index];
+ return ListTile(
+ leading: const Icon(Icons.apartment_outlined),
+ title: Text(office.name),
+ trailing: Wrap(
+ spacing: 8,
+ children: [
+ IconButton(
+ tooltip: 'Edit',
+ icon: const Icon(Icons.edit),
+ onPressed: () => _showOfficeDialog(
+ context,
+ ref,
+ office: office,
+ ),
+ ),
+ IconButton(
+ tooltip: 'Delete',
+ icon: const Icon(Icons.delete),
+ onPressed: () =>
+ _confirmDelete(context, ref, office),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ },
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) =>
+ Center(child: Text('Failed to load offices: $error')),
+ ),
+ ),
+ floatingActionButton: isAdmin
+ ? FloatingActionButton.extended(
+ onPressed: () => _showOfficeDialog(context, ref),
+ icon: const Icon(Icons.add),
+ label: const Text('New Office'),
+ )
+ : null,
+ );
+ }
+
+ Future _showOfficeDialog(
+ BuildContext context,
+ WidgetRef ref, {
+ Office? office,
+ }) async {
+ final nameController = TextEditingController(text: office?.name ?? '');
+
+ await showDialog(
+ context: context,
+ builder: (dialogContext) {
+ return AlertDialog(
+ title: Text(office == null ? 'Create Office' : 'Edit Office'),
+ content: TextField(
+ controller: nameController,
+ decoration: const InputDecoration(labelText: 'Office name'),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () async {
+ final name = nameController.text.trim();
+ if (name.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Name is required.')),
+ );
+ return;
+ }
+ final controller = ref.read(officesControllerProvider);
+ if (office == null) {
+ await controller.createOffice(name: name);
+ } else {
+ await controller.updateOffice(id: office.id, name: name);
+ }
+ ref.invalidate(officesProvider);
+ if (context.mounted) {
+ Navigator.of(dialogContext).pop();
+ }
+ },
+ child: Text(office == null ? 'Create' : 'Save'),
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ Future _confirmDelete(
+ BuildContext context,
+ WidgetRef ref,
+ Office office,
+ ) async {
+ await showDialog(
+ context: context,
+ builder: (dialogContext) {
+ return AlertDialog(
+ title: const Text('Delete Office'),
+ content: Text('Delete ${office.name}? This cannot be undone.'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () async {
+ await ref
+ .read(officesControllerProvider)
+ .deleteOffice(id: office.id);
+ ref.invalidate(officesProvider);
+ if (context.mounted) {
+ Navigator.of(dialogContext).pop();
+ }
+ },
+ child: const Text('Delete'),
+ ),
+ ],
+ );
+ },
+ );
+ }
+}
diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart
new file mode 100644
index 00000000..39b0f45e
--- /dev/null
+++ b/lib/screens/admin/user_management_screen.dart
@@ -0,0 +1,672 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../models/office.dart';
+import '../../models/profile.dart';
+import '../../models/ticket_message.dart';
+import '../../models/user_office.dart';
+import '../../providers/admin_user_provider.dart';
+import '../../providers/profile_provider.dart';
+import '../../providers/tickets_provider.dart';
+import '../../providers/user_offices_provider.dart';
+import '../../widgets/responsive_body.dart';
+
+class UserManagementScreen extends ConsumerStatefulWidget {
+ const UserManagementScreen({super.key});
+
+ @override
+ ConsumerState createState() =>
+ _UserManagementScreenState();
+}
+
+class _UserManagementScreenState extends ConsumerState {
+ static const List _roles = [
+ 'standard',
+ 'dispatcher',
+ 'it_staff',
+ 'admin',
+ ];
+
+ final _fullNameController = TextEditingController();
+
+ String? _selectedUserId;
+ String? _selectedRole;
+ Set _selectedOfficeIds = {};
+ AdminUserStatus? _selectedStatus;
+ bool _isSaving = false;
+ bool _isStatusLoading = false;
+ final Map _statusCache = {};
+ final Set _statusLoading = {};
+ final Set _statusErrors = {};
+ Set _prefetchedUserIds = {};
+
+ @override
+ void dispose() {
+ _fullNameController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isAdmin = ref.watch(isAdminProvider);
+ final profilesAsync = ref.watch(profilesProvider);
+ final officesAsync = ref.watch(officesProvider);
+ final assignmentsAsync = ref.watch(userOfficesProvider);
+ final messagesAsync = ref.watch(ticketMessagesAllProvider);
+
+ return Scaffold(
+ body: ResponsiveBody(
+ maxWidth: 1080,
+ child: !isAdmin
+ ? const Center(child: Text('Admin access required.'))
+ : _buildContent(
+ context,
+ profilesAsync,
+ officesAsync,
+ assignmentsAsync,
+ messagesAsync,
+ ),
+ ),
+ );
+ }
+
+ Widget _buildContent(
+ BuildContext context,
+ AsyncValue> profilesAsync,
+ AsyncValue> officesAsync,
+ AsyncValue> assignmentsAsync,
+ AsyncValue> messagesAsync,
+ ) {
+ if (profilesAsync.isLoading ||
+ officesAsync.isLoading ||
+ assignmentsAsync.isLoading ||
+ messagesAsync.isLoading) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ if (profilesAsync.hasError ||
+ officesAsync.hasError ||
+ assignmentsAsync.hasError ||
+ messagesAsync.hasError) {
+ final error =
+ profilesAsync.error ??
+ officesAsync.error ??
+ assignmentsAsync.error ??
+ messagesAsync.error ??
+ 'Unknown error';
+ return Center(child: Text('Failed to load data: $error'));
+ }
+
+ final profiles = profilesAsync.valueOrNull ?? [];
+ final offices = officesAsync.valueOrNull ?? [];
+ final assignments = assignmentsAsync.valueOrNull ?? [];
+ final messages = messagesAsync.valueOrNull ?? [];
+
+ _prefetchStatuses(profiles);
+
+ final lastActiveByUser = {};
+ for (final message in messages) {
+ final senderId = message.senderId;
+ if (senderId == null) continue;
+ final current = lastActiveByUser[senderId];
+ if (current == null || message.createdAt.isAfter(current)) {
+ lastActiveByUser[senderId] = message.createdAt;
+ }
+ }
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ 'User Management',
+ style: Theme.of(
+ context,
+ ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ const SizedBox(height: 16),
+ Expanded(
+ child: _buildUserTable(
+ context,
+ profiles,
+ offices,
+ assignments,
+ lastActiveByUser,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildUserTable(
+ BuildContext context,
+ List profiles,
+ List offices,
+ List assignments,
+ Map lastActiveByUser,
+ ) {
+ if (profiles.isEmpty) {
+ return const Center(child: Text('No users found.'));
+ }
+ final officeNameById = {
+ for (final office in offices) office.id: office.name,
+ };
+
+ final officeCountByUser = {};
+ for (final assignment in assignments) {
+ officeCountByUser.update(
+ assignment.userId,
+ (value) => value + 1,
+ ifAbsent: () => 1,
+ );
+ }
+
+ return Material(
+ color: Theme.of(context).colorScheme.surface,
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(minWidth: 720),
+ child: SingleChildScrollView(
+ child: DataTable(
+ headingRowHeight: 46,
+ dataRowMinHeight: 48,
+ dataRowMaxHeight: 64,
+ columnSpacing: 24,
+ horizontalMargin: 16,
+ dividerThickness: 1,
+ headingRowColor: MaterialStateProperty.resolveWith(
+ (states) => Theme.of(context).colorScheme.surfaceVariant,
+ ),
+ columns: const [
+ DataColumn(label: Text('User')),
+ DataColumn(label: Text('Email')),
+ DataColumn(label: Text('Role')),
+ DataColumn(label: Text('Offices')),
+ DataColumn(label: Text('Status')),
+ DataColumn(label: Text('Last active')),
+ ],
+ rows: profiles.asMap().entries.map((entry) {
+ final index = entry.key;
+ final profile = entry.value;
+ final label = profile.fullName.isEmpty
+ ? profile.id
+ : profile.fullName;
+ final status = _statusCache[profile.id];
+ final hasError = _statusErrors.contains(profile.id);
+ final isLoading = _statusLoading.contains(profile.id);
+ final email = hasError
+ ? 'Unavailable'
+ : (status?.email ?? (isLoading ? 'Loading...' : 'N/A'));
+ final statusLabel = hasError
+ ? 'Unavailable'
+ : (status == null
+ ? (isLoading ? 'Loading...' : 'Unknown')
+ : (status.isLocked ? 'Locked' : 'Active'));
+ final officeCount = officeCountByUser[profile.id] ?? 0;
+ final officeLabel = officeCount == 0 ? 'None' : '$officeCount';
+ final officeNames = assignments
+ .where((assignment) => assignment.userId == profile.id)
+ .map(
+ (assignment) =>
+ officeNameById[assignment.officeId] ??
+ assignment.officeId,
+ )
+ .toList();
+ final officesText = officeNames.isEmpty
+ ? 'No offices'
+ : officeNames.join(', ');
+ final lastActive = _formatLastActive(
+ lastActiveByUser[profile.id]?.toLocal(),
+ );
+
+ return DataRow.byIndex(
+ index: index,
+ onSelectChanged: (selected) {
+ if (selected != true) return;
+ _showUserDialog(context, profile, offices, assignments);
+ },
+ color: MaterialStateProperty.resolveWith((states) {
+ if (states.contains(MaterialState.selected)) {
+ return Theme.of(
+ context,
+ ).colorScheme.surfaceTint.withOpacity(0.12);
+ }
+ if (index.isEven) {
+ return Theme.of(
+ context,
+ ).colorScheme.surface.withOpacity(0.6);
+ }
+ return Theme.of(context).colorScheme.surface;
+ }),
+ cells: [
+ DataCell(Text(label)),
+ DataCell(Text(email)),
+ DataCell(Text(profile.role)),
+ DataCell(
+ Tooltip(message: officesText, child: Text(officeLabel)),
+ ),
+ DataCell(Text(statusLabel)),
+ DataCell(Text(lastActive)),
+ ],
+ );
+ }).toList(),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ void _ensureStatusLoaded(String userId) {
+ if (_statusCache.containsKey(userId) || _statusLoading.contains(userId)) {
+ return;
+ }
+ _statusLoading.add(userId);
+ _statusErrors.remove(userId);
+ ref
+ .read(adminUserControllerProvider)
+ .fetchStatus(userId)
+ .then((status) {
+ if (!mounted) return;
+ setState(() {
+ _statusCache[userId] = status;
+ _statusLoading.remove(userId);
+ });
+ })
+ .catchError((_) {
+ if (!mounted) return;
+ setState(() {
+ _statusLoading.remove(userId);
+ _statusErrors.add(userId);
+ });
+ });
+ }
+
+ void _prefetchStatuses(List profiles) {
+ final ids = profiles.map((profile) => profile.id).toSet();
+ final missing = ids.difference(_prefetchedUserIds);
+ if (missing.isEmpty) return;
+ _prefetchedUserIds = ids;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ for (final userId in missing) {
+ _ensureStatusLoaded(userId);
+ }
+ });
+ }
+
+ Future _showUserDialog(
+ BuildContext context,
+ Profile profile,
+ List offices,
+ List assignments,
+ ) async {
+ await _selectUser(profile);
+ final currentOfficeIds = assignments
+ .where((assignment) => assignment.userId == profile.id)
+ .map((assignment) => assignment.officeId)
+ .toSet();
+
+ if (!context.mounted) return;
+ await showDialog(
+ context: context,
+ builder: (dialogContext) {
+ return StatefulBuilder(
+ builder: (context, setDialogState) {
+ return AlertDialog(
+ title: const Text('Update user'),
+ content: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 520),
+ child: SingleChildScrollView(
+ child: _buildUserForm(
+ context,
+ profile,
+ offices,
+ currentOfficeIds,
+ setDialogState,
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ child: const Text('Close'),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ );
+ }
+
+ Widget _buildUserForm(
+ BuildContext context,
+ Profile profile,
+ List offices,
+ Set currentOfficeIds,
+ StateSetter setDialogState,
+ ) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ TextFormField(
+ controller: _fullNameController,
+ decoration: const InputDecoration(labelText: 'Full name'),
+ ),
+ const SizedBox(height: 12),
+ DropdownButtonFormField(
+ key: ValueKey('role_${_selectedUserId ?? 'none'}'),
+ initialValue: _selectedRole,
+ items: _roles
+ .map((role) => DropdownMenuItem(value: role, child: Text(role)))
+ .toList(),
+ onChanged: (value) => setDialogState(() => _selectedRole = value),
+ decoration: const InputDecoration(labelText: 'Role'),
+ ),
+ const SizedBox(height: 12),
+ _buildStatusRow(profile),
+ const SizedBox(height: 16),
+ Text('Offices', style: Theme.of(context).textTheme.titleSmall),
+ const SizedBox(height: 8),
+ if (offices.isEmpty) const Text('No offices available.'),
+ if (offices.isNotEmpty)
+ Column(
+ children: offices
+ .map(
+ (office) => CheckboxListTile(
+ value: _selectedOfficeIds.contains(office.id),
+ onChanged: _isSaving
+ ? null
+ : (selected) {
+ setDialogState(() {
+ if (selected == true) {
+ _selectedOfficeIds.add(office.id);
+ } else {
+ _selectedOfficeIds.remove(office.id);
+ }
+ });
+ },
+ title: Text(office.name),
+ controlAffinity: ListTileControlAffinity.leading,
+ contentPadding: EdgeInsets.zero,
+ ),
+ )
+ .toList(),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: FilledButton(
+ onPressed: _isSaving
+ ? null
+ : () async {
+ final saved = await _saveChanges(
+ context,
+ profile,
+ currentOfficeIds,
+ setDialogState,
+ );
+ if (saved && context.mounted) {
+ Navigator.of(context).pop();
+ }
+ },
+ child: _isSaving
+ ? const SizedBox(
+ height: 18,
+ width: 18,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Text('Save changes'),
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildStatusRow(Profile profile) {
+ final email = _selectedStatus?.email;
+ final isLocked = _selectedStatus?.isLocked ?? false;
+ final lockLabel = isLocked ? 'Unlock' : 'Lock';
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Email: ${email ?? 'Loading...'}',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ OutlinedButton.icon(
+ onPressed: _isStatusLoading
+ ? null
+ : () => _showPasswordResetDialog(profile.id),
+ icon: const Icon(Icons.password),
+ label: const Text('Reset password'),
+ ),
+ const SizedBox(width: 12),
+ OutlinedButton.icon(
+ onPressed: _isStatusLoading
+ ? null
+ : () => _toggleLock(profile.id, !isLocked),
+ icon: Icon(isLocked ? Icons.lock_open : Icons.lock),
+ label: Text(lockLabel),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Future _selectUser(Profile profile) async {
+ setState(() {
+ _selectedUserId = profile.id;
+ _selectedRole = profile.role;
+ _fullNameController.text = profile.fullName;
+ _selectedStatus = null;
+ _isStatusLoading = true;
+ });
+
+ final assignments = ref.read(userOfficesProvider).valueOrNull ?? [];
+ final officeIds = assignments
+ .where((assignment) => assignment.userId == profile.id)
+ .map((assignment) => assignment.officeId)
+ .toSet();
+ setState(() => _selectedOfficeIds = officeIds);
+
+ try {
+ final status = await ref
+ .read(adminUserControllerProvider)
+ .fetchStatus(profile.id);
+ if (mounted) {
+ setState(() {
+ _selectedStatus = status;
+ _statusCache[profile.id] = status;
+ });
+ }
+ } catch (_) {
+ if (mounted) {
+ setState(
+ () =>
+ _selectedStatus = AdminUserStatus(email: null, bannedUntil: null),
+ );
+ }
+ } finally {
+ if (mounted) {
+ setState(() => _isStatusLoading = false);
+ }
+ }
+ }
+
+ Future _saveChanges(
+ BuildContext context,
+ Profile profile,
+ Set currentOfficeIds,
+ StateSetter setDialogState,
+ ) async {
+ final role = _selectedRole ?? profile.role;
+ final fullName = _fullNameController.text.trim();
+ if (fullName.isEmpty) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(const SnackBar(content: Text('Full name is required.')));
+ return false;
+ }
+
+ if (_selectedOfficeIds.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Select at least one office.')),
+ );
+ return false;
+ }
+
+ setDialogState(() => _isSaving = true);
+ try {
+ await ref
+ .read(adminUserControllerProvider)
+ .updateProfile(userId: profile.id, fullName: fullName, role: role);
+
+ final toAdd = _selectedOfficeIds.difference(currentOfficeIds);
+ final toRemove = currentOfficeIds.difference(_selectedOfficeIds);
+ final controller = ref.read(userOfficesControllerProvider);
+
+ for (final officeId in toAdd) {
+ await controller.assignUserOffice(
+ userId: profile.id,
+ officeId: officeId,
+ );
+ }
+
+ for (final officeId in toRemove) {
+ await controller.removeUserOffice(
+ userId: profile.id,
+ officeId: officeId,
+ );
+ }
+
+ ref.invalidate(profilesProvider);
+ ref.invalidate(userOfficesProvider);
+
+ if (!context.mounted) return true;
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(const SnackBar(content: Text('User updated.')));
+ return true;
+ } catch (error) {
+ if (!context.mounted) return false;
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Update failed: $error')));
+ return false;
+ } finally {
+ setDialogState(() => _isSaving = false);
+ }
+ }
+
+ Future _showPasswordResetDialog(String userId) async {
+ final controller = TextEditingController();
+ final formKey = GlobalKey();
+
+ await showDialog(
+ context: context,
+ builder: (dialogContext) {
+ return AlertDialog(
+ title: const Text('Set temporary password'),
+ content: Form(
+ key: formKey,
+ child: TextFormField(
+ controller: controller,
+ decoration: const InputDecoration(labelText: 'New password'),
+ obscureText: true,
+ validator: (value) {
+ if (value == null || value.trim().length < 8) {
+ return 'Use at least 8 characters.';
+ }
+ return null;
+ },
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () async {
+ if (!formKey.currentState!.validate()) return;
+ try {
+ await ref
+ .read(adminUserControllerProvider)
+ .setPassword(
+ userId: userId,
+ password: controller.text.trim(),
+ );
+ if (!dialogContext.mounted) return;
+ Navigator.of(dialogContext).pop();
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Password updated.')),
+ );
+ } catch (error) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Reset failed: $error')),
+ );
+ }
+ },
+ child: const Text('Update password'),
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ Future _toggleLock(String userId, bool locked) async {
+ setState(() => _isStatusLoading = true);
+ try {
+ await ref
+ .read(adminUserControllerProvider)
+ .setLock(userId: userId, locked: locked);
+ final status = await ref
+ .read(adminUserControllerProvider)
+ .fetchStatus(userId);
+ if (!mounted) return;
+ setState(() => _selectedStatus = status);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(locked ? 'User locked.' : 'User unlocked.')),
+ );
+ } catch (error) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Lock update failed: $error')));
+ } finally {
+ if (mounted) {
+ setState(() => _isStatusLoading = false);
+ }
+ }
+ }
+
+ String _formatLastActive(DateTime? value) {
+ if (value == null) return 'N/A';
+ final now = DateTime.now();
+ final diff = now.difference(value);
+ if (diff.inMinutes < 1) return 'Just now';
+ if (diff.inHours < 1) return '${diff.inMinutes}m ago';
+ if (diff.inDays < 1) return '${diff.inHours}h ago';
+ if (diff.inDays < 7) return '${diff.inDays}d ago';
+ final month = value.month.toString().padLeft(2, '0');
+ final day = value.day.toString().padLeft(2, '0');
+ return '${value.year}-$month-$day';
+ }
+}
diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart
new file mode 100644
index 00000000..462e1ab3
--- /dev/null
+++ b/lib/screens/auth/login_screen.dart
@@ -0,0 +1,179 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:font_awesome_flutter/font_awesome_flutter.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../providers/auth_provider.dart';
+import '../../widgets/responsive_body.dart';
+
+class LoginScreen extends ConsumerStatefulWidget {
+ const LoginScreen({super.key});
+
+ @override
+ ConsumerState createState() => _LoginScreenState();
+}
+
+class _LoginScreenState extends ConsumerState {
+ final _formKey = GlobalKey();
+ final _emailController = TextEditingController();
+ final _passwordController = TextEditingController();
+
+ bool _isLoading = false;
+
+ @override
+ void dispose() {
+ _emailController.dispose();
+ _passwordController.dispose();
+ super.dispose();
+ }
+
+ Future _handleEmailSignIn() async {
+ if (!_formKey.currentState!.validate()) return;
+ setState(() => _isLoading = true);
+
+ final auth = ref.read(authControllerProvider);
+ try {
+ final response = await auth.signInWithPassword(
+ email: _emailController.text.trim(),
+ password: _passwordController.text,
+ );
+ if (response.session != null && mounted) {
+ context.go('/tickets');
+ } else if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Check your email to confirm sign-in.')),
+ );
+ }
+ } on Exception catch (error) {
+ if (mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Sign in failed: $error')));
+ }
+ } finally {
+ if (mounted) {
+ setState(() => _isLoading = false);
+ }
+ }
+ }
+
+ Future _handleOAuthSignIn({required bool google}) async {
+ setState(() => _isLoading = true);
+ final auth = ref.read(authControllerProvider);
+ final redirectTo = kIsWeb ? Uri.base.origin : null;
+
+ try {
+ if (google) {
+ await auth.signInWithGoogle(redirectTo: redirectTo);
+ } else {
+ await auth.signInWithMeta(redirectTo: redirectTo);
+ }
+ } on Exception catch (error) {
+ if (mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('OAuth failed: $error')));
+ }
+ } finally {
+ if (mounted) {
+ setState(() => _isLoading = false);
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Sign In')),
+ body: ResponsiveBody(
+ maxWidth: 480,
+ padding: const EdgeInsets.symmetric(vertical: 24),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Center(
+ child: Column(
+ children: [
+ Image.asset('assets/tasq_ico.png', height: 72, width: 72),
+ const SizedBox(height: 12),
+ Text(
+ 'TasQ',
+ style: Theme.of(context).textTheme.headlineSmall,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+ TextFormField(
+ controller: _emailController,
+ decoration: const InputDecoration(labelText: 'Email'),
+ keyboardType: TextInputType.emailAddress,
+ textInputAction: TextInputAction.next,
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Email is required.';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _passwordController,
+ decoration: const InputDecoration(labelText: 'Password'),
+ obscureText: true,
+ textInputAction: TextInputAction.done,
+ onFieldSubmitted: (_) {
+ if (!_isLoading) {
+ _handleEmailSignIn();
+ }
+ },
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Password is required.';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 24),
+ FilledButton(
+ onPressed: _isLoading ? null : _handleEmailSignIn,
+ child: _isLoading
+ ? const SizedBox(
+ height: 18,
+ width: 18,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Text('Sign In'),
+ ),
+ const SizedBox(height: 12),
+ OutlinedButton.icon(
+ onPressed: _isLoading
+ ? null
+ : () => _handleOAuthSignIn(google: true),
+ icon: const FaIcon(FontAwesomeIcons.google, size: 18),
+ label: const Text('Continue with Google'),
+ ),
+ const SizedBox(height: 8),
+ OutlinedButton.icon(
+ onPressed: _isLoading
+ ? null
+ : () => _handleOAuthSignIn(google: false),
+ icon: const FaIcon(FontAwesomeIcons.facebook, size: 18),
+ label: const Text('Continue with Meta'),
+ ),
+ const SizedBox(height: 16),
+ TextButton(
+ onPressed: _isLoading ? null : () => context.go('/signup'),
+ child: const Text('Create account'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/auth/signup_screen.dart b/lib/screens/auth/signup_screen.dart
new file mode 100644
index 00000000..b1469362
--- /dev/null
+++ b/lib/screens/auth/signup_screen.dart
@@ -0,0 +1,275 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../providers/auth_provider.dart';
+import '../../providers/tickets_provider.dart';
+import '../../widgets/responsive_body.dart';
+
+class SignUpScreen extends ConsumerStatefulWidget {
+ const SignUpScreen({super.key});
+
+ @override
+ ConsumerState createState() => _SignUpScreenState();
+}
+
+class _SignUpScreenState extends ConsumerState {
+ final _formKey = GlobalKey();
+ final _fullNameController = TextEditingController();
+ final _emailController = TextEditingController();
+ final _passwordController = TextEditingController();
+ final _confirmPasswordController = TextEditingController();
+
+ final Set _selectedOfficeIds = {};
+ double _passwordStrength = 0.0;
+ String _passwordStrengthLabel = 'Very weak';
+ Color _passwordStrengthColor = Colors.red;
+
+ bool _isLoading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _passwordController.addListener(_updatePasswordStrength);
+ }
+
+ @override
+ void dispose() {
+ _passwordController.removeListener(_updatePasswordStrength);
+ _fullNameController.dispose();
+ _emailController.dispose();
+ _passwordController.dispose();
+ _confirmPasswordController.dispose();
+ super.dispose();
+ }
+
+ Future _handleSignUp() async {
+ if (!_formKey.currentState!.validate()) return;
+ if (_selectedOfficeIds.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Select at least one office.')),
+ );
+ return;
+ }
+ setState(() => _isLoading = true);
+
+ final auth = ref.read(authControllerProvider);
+ try {
+ await auth.signUp(
+ email: _emailController.text.trim(),
+ password: _passwordController.text,
+ fullName: _fullNameController.text.trim(),
+ officeIds: _selectedOfficeIds.toList(),
+ );
+ if (mounted) {
+ context.go('/login');
+ }
+ } on Exception catch (error) {
+ if (mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Sign up failed: $error')));
+ }
+ } finally {
+ if (mounted) {
+ setState(() => _isLoading = false);
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final officesAsync = ref.watch(officesOnceProvider);
+ return Scaffold(
+ appBar: AppBar(title: const Text('Create Account')),
+ body: ResponsiveBody(
+ maxWidth: 480,
+ padding: const EdgeInsets.symmetric(vertical: 24),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Center(
+ child: Column(
+ children: [
+ Image.asset('assets/tasq_ico.png', height: 72, width: 72),
+ const SizedBox(height: 12),
+ Text(
+ 'TasQ',
+ style: Theme.of(context).textTheme.headlineSmall,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+ TextFormField(
+ controller: _fullNameController,
+ decoration: const InputDecoration(labelText: 'Full name'),
+ textInputAction: TextInputAction.next,
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Full name is required.';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _emailController,
+ decoration: const InputDecoration(labelText: 'Email'),
+ keyboardType: TextInputType.emailAddress,
+ textInputAction: TextInputAction.next,
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Email is required.';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _passwordController,
+ decoration: const InputDecoration(labelText: 'Password'),
+ obscureText: true,
+ textInputAction: TextInputAction.next,
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Password is required.';
+ }
+ if (value.length < 6) {
+ return 'Use at least 6 characters.';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 8),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Password strength: $_passwordStrengthLabel',
+ style: Theme.of(context).textTheme.labelMedium,
+ ),
+ const SizedBox(height: 6),
+ LinearProgressIndicator(
+ value: _passwordStrength,
+ minHeight: 8,
+ borderRadius: BorderRadius.circular(8),
+ color: _passwordStrengthColor,
+ backgroundColor: Theme.of(
+ context,
+ ).colorScheme.surfaceContainerHighest,
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _confirmPasswordController,
+ decoration: const InputDecoration(
+ labelText: 'Confirm password',
+ ),
+ obscureText: true,
+ textInputAction: TextInputAction.done,
+ onFieldSubmitted: (_) {
+ if (!_isLoading) {
+ _handleSignUp();
+ }
+ },
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Confirm your password.';
+ }
+ if (value != _passwordController.text) {
+ return 'Passwords do not match.';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+ Text('Offices', style: Theme.of(context).textTheme.titleSmall),
+ const SizedBox(height: 8),
+ officesAsync.when(
+ data: (offices) {
+ if (offices.isEmpty) {
+ return const Text('No offices available.');
+ }
+ return Column(
+ children: offices
+ .map(
+ (office) => CheckboxListTile(
+ value: _selectedOfficeIds.contains(office.id),
+ onChanged: _isLoading
+ ? null
+ : (selected) {
+ setState(() {
+ if (selected == true) {
+ _selectedOfficeIds.add(office.id);
+ } else {
+ _selectedOfficeIds.remove(office.id);
+ }
+ });
+ },
+ title: Text(office.name),
+ controlAffinity: ListTileControlAffinity.leading,
+ contentPadding: EdgeInsets.zero,
+ ),
+ )
+ .toList(),
+ );
+ },
+ loading: () => const LinearProgressIndicator(),
+ error: (error, _) => Text('Failed to load offices: $error'),
+ ),
+ const SizedBox(height: 24),
+ FilledButton(
+ onPressed: _isLoading ? null : _handleSignUp,
+ child: _isLoading
+ ? const SizedBox(
+ height: 18,
+ width: 18,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Text('Create Account'),
+ ),
+ const SizedBox(height: 12),
+ TextButton(
+ onPressed: _isLoading ? null : () => context.go('/login'),
+ child: const Text('Back to sign in'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ void _updatePasswordStrength() {
+ final text = _passwordController.text;
+ var score = 0;
+ if (text.length >= 8) score++;
+ if (text.length >= 12) score++;
+ if (RegExp(r'[A-Z]').hasMatch(text)) score++;
+ if (RegExp(r'[a-z]').hasMatch(text)) score++;
+ if (RegExp(r'\d').hasMatch(text)) score++;
+ if (RegExp(r'[!@#$%^&*(),.?":{}|<>\[\]\\/+=;_-]').hasMatch(text)) {
+ score++;
+ }
+
+ final normalized = (score / 6).clamp(0.0, 1.0);
+ final (label, color) = switch (normalized) {
+ <= 0.2 => ('Very weak', Colors.red),
+ <= 0.4 => ('Weak', Colors.deepOrange),
+ <= 0.6 => ('Fair', Colors.orange),
+ <= 0.8 => ('Strong', Colors.green),
+ _ => ('Excellent', Colors.teal),
+ };
+
+ setState(() {
+ _passwordStrength = normalized;
+ _passwordStrengthLabel = label;
+ _passwordStrengthColor = color;
+ });
+ }
+}
diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart
new file mode 100644
index 00000000..ad7cc021
--- /dev/null
+++ b/lib/screens/dashboard/dashboard_screen.dart
@@ -0,0 +1,637 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../models/profile.dart';
+import '../../models/task.dart';
+import '../../models/task_assignment.dart';
+import '../../models/ticket.dart';
+import '../../models/ticket_message.dart';
+import '../../providers/profile_provider.dart';
+import '../../providers/tasks_provider.dart';
+import '../../providers/tickets_provider.dart';
+import '../../widgets/responsive_body.dart';
+
+class DashboardMetrics {
+ DashboardMetrics({
+ required this.newTicketsToday,
+ required this.closedToday,
+ required this.openTickets,
+ required this.avgResponse,
+ required this.avgTriage,
+ required this.longestResponse,
+ required this.tasksCreatedToday,
+ required this.tasksCompletedToday,
+ required this.openTasks,
+ required this.staffRows,
+ });
+
+ final int newTicketsToday;
+ final int closedToday;
+ final int openTickets;
+
+ final Duration? avgResponse;
+ final Duration? avgTriage;
+ final Duration? longestResponse;
+
+ final int tasksCreatedToday;
+ final int tasksCompletedToday;
+ final int openTasks;
+
+ final List staffRows;
+}
+
+class StaffRowMetrics {
+ StaffRowMetrics({
+ required this.userId,
+ required this.name,
+ required this.status,
+ required this.ticketsRespondedToday,
+ required this.tasksClosedToday,
+ });
+
+ final String userId;
+ final String name;
+ final String status;
+ final int ticketsRespondedToday;
+ final int tasksClosedToday;
+}
+
+final dashboardMetricsProvider = Provider>((ref) {
+ final ticketsAsync = ref.watch(ticketsProvider);
+ final tasksAsync = ref.watch(tasksProvider);
+ final profilesAsync = ref.watch(profilesProvider);
+ final assignmentsAsync = ref.watch(taskAssignmentsProvider);
+ final messagesAsync = ref.watch(ticketMessagesAllProvider);
+
+ final asyncValues = [
+ ticketsAsync,
+ tasksAsync,
+ profilesAsync,
+ assignmentsAsync,
+ messagesAsync,
+ ];
+
+ if (asyncValues.any((value) => value.hasError)) {
+ final errorValue = asyncValues.firstWhere((value) => value.hasError);
+ final error = errorValue.error ?? 'Failed to load dashboard';
+ final stack = errorValue.stackTrace ?? StackTrace.current;
+ return AsyncError(error, stack);
+ }
+
+ if (asyncValues.any((value) => value.isLoading)) {
+ return const AsyncLoading();
+ }
+
+ final tickets = ticketsAsync.valueOrNull ?? const [];
+ final tasks = tasksAsync.valueOrNull ?? const [];
+ final profiles = profilesAsync.valueOrNull ?? const [];
+ final assignments = assignmentsAsync.valueOrNull ?? const [];
+ final messages = messagesAsync.valueOrNull ?? const [];
+
+ final now = DateTime.now();
+ final startOfDay = DateTime(now.year, now.month, now.day);
+
+ final staffProfiles = profiles
+ .where((profile) => profile.role == 'it_staff')
+ .toList();
+ final staffIds = profiles
+ .where(
+ (profile) =>
+ profile.role == 'admin' ||
+ profile.role == 'dispatcher' ||
+ profile.role == 'it_staff',
+ )
+ .map((profile) => profile.id)
+ .toSet();
+
+ bool isToday(DateTime value) => !value.isBefore(startOfDay);
+
+ final firstStaffMessageByTicket = {};
+ final lastStaffMessageByUser = {};
+ final respondedTicketsByUser = >{};
+ for (final message in messages) {
+ final ticketId = message.ticketId;
+ final senderId = message.senderId;
+ if (ticketId != null && senderId != null && staffIds.contains(senderId)) {
+ final current = firstStaffMessageByTicket[ticketId];
+ if (current == null || message.createdAt.isBefore(current)) {
+ firstStaffMessageByTicket[ticketId] = message.createdAt;
+ }
+ final last = lastStaffMessageByUser[senderId];
+ if (last == null || message.createdAt.isAfter(last)) {
+ lastStaffMessageByUser[senderId] = message.createdAt;
+ }
+ if (isToday(message.createdAt)) {
+ respondedTicketsByUser
+ .putIfAbsent(senderId, () => {})
+ .add(ticketId);
+ }
+ }
+ }
+
+ DateTime? respondedAtForTicket(Ticket ticket) {
+ final staffMessageAt = firstStaffMessageByTicket[ticket.id];
+ if (staffMessageAt != null) {
+ return staffMessageAt;
+ }
+ if (ticket.promotedAt != null) {
+ return ticket.promotedAt;
+ }
+ return null;
+ }
+
+ Duration? responseDuration(Ticket ticket) {
+ final respondedAt = respondedAtForTicket(ticket);
+ if (respondedAt == null) {
+ return null;
+ }
+ final duration = respondedAt.difference(ticket.createdAt);
+ return duration.isNegative ? Duration.zero : duration;
+ }
+
+ Duration? triageDuration(Ticket ticket) {
+ final respondedAt = respondedAtForTicket(ticket);
+ if (respondedAt == null) {
+ return null;
+ }
+ final triageEnd = _earliestDate(ticket.promotedAt, ticket.closedAt);
+ if (triageEnd == null) {
+ return null;
+ }
+ final duration = triageEnd.difference(respondedAt);
+ return duration.isNegative ? Duration.zero : duration;
+ }
+
+ final ticketsToday = tickets.where((ticket) => isToday(ticket.createdAt));
+ final closedToday = tickets.where(
+ (ticket) => ticket.closedAt != null && isToday(ticket.closedAt!),
+ );
+ final openTickets = tickets.where((ticket) => ticket.status != 'closed');
+
+ final responseDurationsToday = ticketsToday
+ .map(responseDuration)
+ .whereType()
+ .toList();
+ final triageDurationsToday = ticketsToday
+ .map(triageDuration)
+ .whereType()
+ .toList();
+
+ final avgResponse = _averageDuration(responseDurationsToday);
+ final avgTriage = _averageDuration(triageDurationsToday);
+ final longestResponse = responseDurationsToday.isEmpty
+ ? null
+ : responseDurationsToday.reduce(
+ (a, b) => a.inSeconds >= b.inSeconds ? a : b,
+ );
+
+ final tasksCreatedToday = tasks.where((task) => isToday(task.createdAt));
+ final tasksCompletedToday = tasks.where(
+ (task) => task.completedAt != null && isToday(task.completedAt!),
+ );
+ final openTasks = tasks.where((task) => task.status != 'completed');
+
+ final taskById = {for (final task in tasks) task.id: task};
+ final staffOnTask = {};
+ for (final assignment in assignments) {
+ final task = taskById[assignment.taskId];
+ if (task == null) {
+ continue;
+ }
+ if (task.status == 'in_progress') {
+ staffOnTask.add(assignment.userId);
+ }
+ }
+
+ final tasksClosedByUser = >{};
+ for (final assignment in assignments) {
+ final task = taskById[assignment.taskId];
+ if (task == null || task.completedAt == null) {
+ continue;
+ }
+ if (!isToday(task.completedAt!)) {
+ continue;
+ }
+ tasksClosedByUser
+ .putIfAbsent(assignment.userId, () => {})
+ .add(task.id);
+ }
+
+ const triageWindow = Duration(minutes: 1);
+ final triageCutoff = now.subtract(triageWindow);
+
+ final staffRows = staffProfiles.map((staff) {
+ final lastMessage = lastStaffMessageByUser[staff.id];
+ final ticketsResponded = respondedTicketsByUser[staff.id]?.length ?? 0;
+ final tasksClosed = tasksClosedByUser[staff.id]?.length ?? 0;
+ final onTask = staffOnTask.contains(staff.id);
+ final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff);
+ final status = onTask
+ ? 'On task'
+ : inTriage
+ ? 'In triage'
+ : 'Vacant';
+
+ return StaffRowMetrics(
+ userId: staff.id,
+ name: staff.fullName.isNotEmpty ? staff.fullName : staff.id,
+ status: status,
+ ticketsRespondedToday: ticketsResponded,
+ tasksClosedToday: tasksClosed,
+ );
+ }).toList();
+
+ return AsyncData(
+ DashboardMetrics(
+ newTicketsToday: ticketsToday.length,
+ closedToday: closedToday.length,
+ openTickets: openTickets.length,
+ avgResponse: avgResponse,
+ avgTriage: avgTriage,
+ longestResponse: longestResponse,
+ tasksCreatedToday: tasksCreatedToday.length,
+ tasksCompletedToday: tasksCompletedToday.length,
+ openTasks: openTasks.length,
+ staffRows: staffRows,
+ ),
+ );
+});
+
+class DashboardScreen extends StatelessWidget {
+ const DashboardScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return ResponsiveBody(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final isWide = constraints.maxWidth >= 980;
+ final metricsColumn = Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(top: 16, bottom: 8),
+ child: Align(
+ alignment: Alignment.center,
+ child: Text(
+ 'Dashboard',
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ),
+ const _DashboardStatusBanner(),
+ _sectionTitle(context, 'Core Daily KPIs'),
+ _cardGrid(context, [
+ _MetricCard(
+ title: 'New tickets today',
+ valueBuilder: (metrics) => metrics.newTicketsToday.toString(),
+ ),
+ _MetricCard(
+ title: 'Closed today',
+ valueBuilder: (metrics) => metrics.closedToday.toString(),
+ ),
+ _MetricCard(
+ title: 'Open tickets',
+ valueBuilder: (metrics) => metrics.openTickets.toString(),
+ ),
+ ]),
+ const SizedBox(height: 20),
+ _sectionTitle(context, 'TAT / Response'),
+ _cardGrid(context, [
+ _MetricCard(
+ title: 'Avg response',
+ valueBuilder: (metrics) =>
+ _formatDuration(metrics.avgResponse),
+ ),
+ _MetricCard(
+ title: 'Avg triage',
+ valueBuilder: (metrics) => _formatDuration(metrics.avgTriage),
+ ),
+ _MetricCard(
+ title: 'Longest response',
+ valueBuilder: (metrics) =>
+ _formatDuration(metrics.longestResponse),
+ ),
+ ]),
+ const SizedBox(height: 20),
+ _sectionTitle(context, 'Task Flow'),
+ _cardGrid(context, [
+ _MetricCard(
+ title: 'Tasks created',
+ valueBuilder: (metrics) =>
+ metrics.tasksCreatedToday.toString(),
+ ),
+ _MetricCard(
+ title: 'Tasks completed',
+ valueBuilder: (metrics) =>
+ metrics.tasksCompletedToday.toString(),
+ ),
+ _MetricCard(
+ title: 'Open tasks',
+ valueBuilder: (metrics) => metrics.openTasks.toString(),
+ ),
+ ]),
+ ],
+ );
+
+ final staffColumn = Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SizedBox(height: 16),
+ _sectionTitle(context, 'IT Staff Pulse'),
+ const _StaffTable(),
+ ],
+ );
+
+ if (isWide) {
+ return Center(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: constraints.maxHeight),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ ConstrainedBox(
+ constraints: BoxConstraints(
+ maxWidth: constraints.maxWidth * 0.6,
+ ),
+ child: metricsColumn,
+ ),
+ const SizedBox(width: 20),
+ ConstrainedBox(
+ constraints: BoxConstraints(
+ maxWidth: constraints.maxWidth * 0.35,
+ ),
+ child: staffColumn,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ return SingleChildScrollView(
+ padding: const EdgeInsets.only(bottom: 24),
+ child: Center(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: constraints.maxHeight),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ metricsColumn,
+ const SizedBox(height: 12),
+ staffColumn,
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _sectionTitle(BuildContext context, String title) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: Text(
+ title,
+ style: Theme.of(
+ context,
+ ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ );
+ }
+
+ Widget _cardGrid(BuildContext context, List cards) {
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final width = constraints.maxWidth;
+ final columns = width >= 900
+ ? 3
+ : width >= 620
+ ? 2
+ : 1;
+ final spacing = 12.0;
+ final cardWidth = (width - (columns - 1) * spacing) / columns;
+ return Wrap(
+ alignment: WrapAlignment.center,
+ runAlignment: WrapAlignment.center,
+ spacing: spacing,
+ runSpacing: spacing,
+ children: cards
+ .map((card) => SizedBox(width: cardWidth, child: card))
+ .toList(),
+ );
+ },
+ );
+ }
+}
+
+class _DashboardStatusBanner extends ConsumerWidget {
+ const _DashboardStatusBanner();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final metricsAsync = ref.watch(dashboardMetricsProvider);
+ return metricsAsync.when(
+ data: (_) => const SizedBox.shrink(),
+ loading: () => const Padding(
+ padding: EdgeInsets.only(bottom: 12),
+ child: LinearProgressIndicator(minHeight: 2),
+ ),
+ error: (error, _) => Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: Text(
+ 'Dashboard data error: $error',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.error,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _MetricCard extends ConsumerWidget {
+ const _MetricCard({required this.title, required this.valueBuilder});
+
+ final String title;
+ final String Function(DashboardMetrics metrics) valueBuilder;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final metricsAsync = ref.watch(dashboardMetricsProvider);
+ final value = metricsAsync.when(
+ data: (metrics) => valueBuilder(metrics),
+ loading: () => '—',
+ error: (error, _) => 'Error',
+ );
+
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: Theme.of(
+ context,
+ ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ value,
+ style: Theme.of(
+ context,
+ ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _StaffTable extends StatelessWidget {
+ const _StaffTable();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: const [
+ _StaffTableHeader(),
+ SizedBox(height: 8),
+ _StaffTableBody(),
+ ],
+ ),
+ );
+ }
+}
+
+class _StaffTableHeader extends StatelessWidget {
+ const _StaffTableHeader();
+
+ @override
+ Widget build(BuildContext context) {
+ final style = Theme.of(
+ context,
+ ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700);
+ return Row(
+ children: [
+ Expanded(flex: 3, child: Text('IT Staff', style: style)),
+ Expanded(flex: 2, child: Text('Status', style: style)),
+ Expanded(flex: 2, child: Text('Tickets', style: style)),
+ Expanded(flex: 2, child: Text('Tasks', style: style)),
+ ],
+ );
+ }
+}
+
+class _StaffTableBody extends ConsumerWidget {
+ const _StaffTableBody();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final metricsAsync = ref.watch(dashboardMetricsProvider);
+ return metricsAsync.when(
+ data: (metrics) {
+ if (metrics.staffRows.isEmpty) {
+ return Text(
+ 'No IT staff available.',
+ style: Theme.of(context).textTheme.bodySmall,
+ );
+ }
+ return Column(
+ children: metrics.staffRows
+ .map((row) => _StaffRow(row: row))
+ .toList(),
+ );
+ },
+ loading: () => const Text('Loading staff...'),
+ error: (error, _) => Text('Failed to load staff: $error'),
+ );
+ }
+}
+
+class _StaffRow extends StatelessWidget {
+ const _StaffRow({required this.row});
+
+ final StaffRowMetrics row;
+
+ @override
+ Widget build(BuildContext context) {
+ final valueStyle = Theme.of(context).textTheme.bodySmall;
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 6),
+ child: Row(
+ children: [
+ Expanded(flex: 3, child: Text(row.name, style: valueStyle)),
+ Expanded(flex: 2, child: Text(row.status, style: valueStyle)),
+ Expanded(
+ flex: 2,
+ child: Text(
+ row.ticketsRespondedToday.toString(),
+ style: valueStyle,
+ ),
+ ),
+ Expanded(
+ flex: 2,
+ child: Text(row.tasksClosedToday.toString(), style: valueStyle),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+Duration? _averageDuration(List durations) {
+ if (durations.isEmpty) {
+ return null;
+ }
+ final totalSeconds = durations
+ .map((duration) => duration.inSeconds)
+ .reduce((a, b) => a + b);
+ return Duration(seconds: (totalSeconds / durations.length).round());
+}
+
+DateTime? _earliestDate(DateTime? first, DateTime? second) {
+ if (first == null) return second;
+ if (second == null) return first;
+ return first.isBefore(second) ? first : second;
+}
+
+String _formatDuration(Duration? duration) {
+ if (duration == null) {
+ return 'Pending';
+ }
+ if (duration.inSeconds < 60) {
+ return 'Less than a minute';
+ }
+ final hours = duration.inHours;
+ final minutes = duration.inMinutes.remainder(60);
+ if (hours > 0) {
+ return '${hours}h ${minutes}m';
+ }
+ return '${minutes}m';
+}
diff --git a/lib/screens/notifications/notifications_screen.dart b/lib/screens/notifications/notifications_screen.dart
new file mode 100644
index 00000000..31568890
--- /dev/null
+++ b/lib/screens/notifications/notifications_screen.dart
@@ -0,0 +1,147 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../providers/notifications_provider.dart';
+import '../../providers/profile_provider.dart';
+import '../../providers/tasks_provider.dart';
+import '../../providers/tickets_provider.dart';
+import '../../widgets/responsive_body.dart';
+
+class NotificationsScreen extends ConsumerWidget {
+ const NotificationsScreen({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final notificationsAsync = ref.watch(notificationsProvider);
+ final profilesAsync = ref.watch(profilesProvider);
+ final ticketsAsync = ref.watch(ticketsProvider);
+ final tasksAsync = ref.watch(tasksProvider);
+
+ final profileById = {
+ for (final profile in profilesAsync.valueOrNull ?? [])
+ profile.id: profile,
+ };
+ final ticketById = {
+ for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket,
+ };
+ final taskById = {
+ for (final task in tasksAsync.valueOrNull ?? []) task.id: task,
+ };
+
+ return ResponsiveBody(
+ child: notificationsAsync.when(
+ data: (items) {
+ if (items.isEmpty) {
+ return const Center(child: Text('No notifications yet.'));
+ }
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(top: 16, bottom: 8),
+ child: Text(
+ 'Notifications',
+ style: Theme.of(
+ context,
+ ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ ),
+ Expanded(
+ child: ListView.separated(
+ padding: const EdgeInsets.only(bottom: 24),
+ itemCount: items.length,
+ separatorBuilder: (context, index) =>
+ const SizedBox(height: 12),
+ itemBuilder: (context, index) {
+ final item = items[index];
+ final actorName = item.actorId == null
+ ? 'System'
+ : (profileById[item.actorId]?.fullName ??
+ item.actorId!);
+ final ticketSubject = item.ticketId == null
+ ? 'Ticket'
+ : (ticketById[item.ticketId]?.subject ??
+ item.ticketId!);
+ final taskTitle = item.taskId == null
+ ? 'Task'
+ : (taskById[item.taskId]?.title ?? item.taskId!);
+ final subtitle = item.taskId != null
+ ? taskTitle
+ : ticketSubject;
+
+ final title = _notificationTitle(item.type, actorName);
+ final icon = _notificationIcon(item.type);
+
+ return ListTile(
+ leading: Icon(icon),
+ title: Text(title),
+ subtitle: Text(subtitle),
+ trailing: item.isUnread
+ ? const Icon(
+ Icons.circle,
+ size: 10,
+ color: Colors.red,
+ )
+ : null,
+ onTap: () async {
+ final ticketId = item.ticketId;
+ final taskId = item.taskId;
+ if (ticketId != null) {
+ await ref
+ .read(notificationsControllerProvider)
+ .markReadForTicket(ticketId);
+ } else if (taskId != null) {
+ await ref
+ .read(notificationsControllerProvider)
+ .markReadForTask(taskId);
+ } else if (item.isUnread) {
+ await ref
+ .read(notificationsControllerProvider)
+ .markRead(item.id);
+ }
+ if (!context.mounted) return;
+ if (taskId != null) {
+ context.go('/tasks/$taskId');
+ } else if (ticketId != null) {
+ context.go('/tickets/$ticketId');
+ }
+ },
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ },
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) =>
+ Center(child: Text('Failed to load notifications: $error')),
+ ),
+ );
+ }
+
+ String _notificationTitle(String type, String actorName) {
+ switch (type) {
+ case 'assignment':
+ return '$actorName assigned you';
+ case 'created':
+ return '$actorName created a new item';
+ case 'mention':
+ default:
+ return '$actorName mentioned you';
+ }
+ }
+
+ IconData _notificationIcon(String type) {
+ switch (type) {
+ case 'assignment':
+ return Icons.assignment_ind_outlined;
+ case 'created':
+ return Icons.campaign_outlined;
+ case 'mention':
+ default:
+ return Icons.alternate_email;
+ }
+ }
+}
diff --git a/lib/screens/shared/under_development_screen.dart b/lib/screens/shared/under_development_screen.dart
new file mode 100644
index 00000000..c897cbc9
--- /dev/null
+++ b/lib/screens/shared/under_development_screen.dart
@@ -0,0 +1,84 @@
+import 'package:flutter/material.dart';
+
+import '../../widgets/responsive_body.dart';
+
+class UnderDevelopmentScreen extends StatelessWidget {
+ const UnderDevelopmentScreen({
+ super.key,
+ required this.title,
+ required this.subtitle,
+ required this.icon,
+ });
+
+ final String title;
+ final String subtitle;
+ final IconData icon;
+
+ @override
+ Widget build(BuildContext context) {
+ return ResponsiveBody(
+ maxWidth: 720,
+ padding: const EdgeInsets.symmetric(vertical: 32),
+ child: Center(
+ child: Card(
+ child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 72,
+ height: 72,
+ decoration: BoxDecoration(
+ color: Theme.of(
+ context,
+ ).colorScheme.primaryContainer.withValues(alpha: 0.7),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Icon(
+ icon,
+ size: 36,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ ),
+ const SizedBox(height: 20),
+ Text(
+ title,
+ style: Theme.of(context).textTheme.headlineSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ subtitle,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 20),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 8,
+ ),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(999),
+ color: Theme.of(
+ context,
+ ).colorScheme.surfaceContainerHighest,
+ ),
+ child: Text(
+ 'Under development',
+ style: Theme.of(context).textTheme.labelLarge,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart
new file mode 100644
index 00000000..bac414ee
--- /dev/null
+++ b/lib/screens/tasks/task_detail_screen.dart
@@ -0,0 +1,822 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../models/profile.dart';
+import '../../models/task.dart';
+import '../../models/task_assignment.dart';
+import '../../models/ticket.dart';
+import '../../models/ticket_message.dart';
+import '../../providers/notifications_provider.dart';
+import '../../providers/profile_provider.dart';
+import '../../providers/tasks_provider.dart';
+import '../../providers/tickets_provider.dart';
+import '../../providers/typing_provider.dart';
+import '../../widgets/responsive_body.dart';
+import '../../widgets/task_assignment_section.dart';
+import '../../widgets/typing_dots.dart';
+
+class TaskDetailScreen extends ConsumerStatefulWidget {
+ const TaskDetailScreen({super.key, required this.taskId});
+
+ final String taskId;
+
+ @override
+ ConsumerState createState() => _TaskDetailScreenState();
+}
+
+class _TaskDetailScreenState extends ConsumerState {
+ final _messageController = TextEditingController();
+ static const List _statusOptions = [
+ 'queued',
+ 'in_progress',
+ 'completed',
+ ];
+ String? _mentionQuery;
+ int? _mentionStart;
+ List _mentionResults = [];
+
+ @override
+ void initState() {
+ super.initState();
+ Future.microtask(
+ () => ref
+ .read(notificationsControllerProvider)
+ .markReadForTask(widget.taskId),
+ );
+ }
+
+ @override
+ void dispose() {
+ _messageController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final tasksAsync = ref.watch(tasksProvider);
+ final ticketsAsync = ref.watch(ticketsProvider);
+ final officesAsync = ref.watch(officesProvider);
+ final profileAsync = ref.watch(currentProfileProvider);
+ final assignmentsAsync = ref.watch(taskAssignmentsProvider);
+ final taskMessagesAsync = ref.watch(taskMessagesProvider(widget.taskId));
+ final profilesAsync = ref.watch(profilesProvider);
+
+ final task = _findTask(tasksAsync, widget.taskId);
+ if (task == null) {
+ return const ResponsiveBody(
+ child: Center(child: Text('Task not found.')),
+ );
+ }
+
+ final ticketId = task.ticketId;
+ final typingChannelId = task.id;
+ final ticket = ticketId == null
+ ? null
+ : _findTicket(ticketsAsync, ticketId);
+ final officeById = {
+ for (final office in officesAsync.valueOrNull ?? []) office.id: office,
+ };
+ final officeId = ticket?.officeId ?? task.officeId;
+ final officeName = officeId == null
+ ? 'Unassigned office'
+ : (officeById[officeId]?.name ?? officeId);
+ final description = ticket?.description ?? task.description;
+
+ final canAssign = profileAsync.maybeWhen(
+ data: (profile) => profile != null && _canAssignStaff(profile.role),
+ orElse: () => false,
+ );
+ final showAssign = canAssign && task.status != 'completed';
+ final assignments = assignmentsAsync.valueOrNull ?? [];
+ final canUpdateStatus = _canUpdateStatus(
+ profileAsync.valueOrNull,
+ assignments,
+ task.id,
+ );
+ final typingState = ref.watch(typingIndicatorProvider(typingChannelId));
+ final canSendMessages = task.status != 'completed';
+
+ final messagesAsync = _mergeMessages(
+ taskMessagesAsync,
+ ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)),
+ );
+
+ return ResponsiveBody(
+ child: Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(top: 16, bottom: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Align(
+ alignment: Alignment.center,
+ child: Text(
+ task.title.isNotEmpty ? task.title : 'Task ${task.id}',
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ const SizedBox(height: 6),
+ Align(
+ alignment: Alignment.center,
+ child: Text(
+ _createdByLabel(profilesAsync, task, ticket),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 12,
+ runSpacing: 8,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ _buildStatusChip(context, task, canUpdateStatus),
+ Text('Office: $officeName'),
+ ],
+ ),
+ if (description.isNotEmpty) ...[
+ const SizedBox(height: 12),
+ Text(description),
+ ],
+ const SizedBox(height: 12),
+ _buildTatSection(task),
+ const SizedBox(height: 16),
+ TaskAssignmentSection(taskId: task.id, canAssign: showAssign),
+ ],
+ ),
+ ),
+ const Divider(height: 1),
+ Expanded(
+ child: messagesAsync.when(
+ data: (messages) => _buildMessages(
+ context,
+ messages,
+ profilesAsync.valueOrNull ?? [],
+ ),
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) =>
+ Center(child: Text('Failed to load messages: $error')),
+ ),
+ ),
+ SafeArea(
+ top: false,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(0, 8, 0, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (typingState.userIds.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 6),
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 6,
+ ),
+ decoration: BoxDecoration(
+ color: Theme.of(
+ context,
+ ).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ _typingLabel(typingState.userIds, profilesAsync),
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ const SizedBox(width: 8),
+ TypingDots(
+ size: 8,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (_mentionQuery != null)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: _buildMentionList(profilesAsync),
+ ),
+ if (!canSendMessages)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Text(
+ 'Messaging is disabled for completed tasks.',
+ style: Theme.of(context).textTheme.labelMedium,
+ ),
+ ),
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _messageController,
+ decoration: const InputDecoration(
+ hintText: 'Message...',
+ ),
+ textInputAction: TextInputAction.send,
+ enabled: canSendMessages,
+ onChanged: (_) => _handleComposerChanged(
+ profilesAsync.valueOrNull ?? [],
+ ref.read(currentUserIdProvider),
+ canSendMessages,
+ typingChannelId,
+ ),
+ onSubmitted: (_) => _handleSendMessage(
+ task,
+ profilesAsync.valueOrNull ?? [],
+ ref.read(currentUserIdProvider),
+ canSendMessages,
+ typingChannelId,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ IconButton(
+ tooltip: 'Send',
+ onPressed: canSendMessages
+ ? () => _handleSendMessage(
+ task,
+ profilesAsync.valueOrNull ?? [],
+ ref.read(currentUserIdProvider),
+ canSendMessages,
+ typingChannelId,
+ )
+ : null,
+ icon: const Icon(Icons.send),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ String _createdByLabel(
+ AsyncValue> profilesAsync,
+ Task task,
+ Ticket? ticket,
+ ) {
+ final creatorId = task.creatorId ?? ticket?.creatorId;
+ if (creatorId == null || creatorId.isEmpty) {
+ return 'Created by: Unknown';
+ }
+ final profile = profilesAsync.valueOrNull
+ ?.where((item) => item.id == creatorId)
+ .firstOrNull;
+ final name = profile?.fullName.isNotEmpty == true
+ ? profile!.fullName
+ : creatorId;
+ return 'Created by: $name';
+ }
+
+ Widget _buildMessages(
+ BuildContext context,
+ List messages,
+ List profiles,
+ ) {
+ if (messages.isEmpty) {
+ return const Center(child: Text('No messages yet.'));
+ }
+ final profileById = {for (final profile in profiles) profile.id: profile};
+ final currentUserId = ref.read(currentUserIdProvider);
+
+ return ListView.builder(
+ reverse: true,
+ padding: const EdgeInsets.fromLTRB(0, 16, 0, 72),
+ itemCount: messages.length,
+ itemBuilder: (context, index) {
+ final message = messages[index];
+ final isMe = currentUserId != null && message.senderId == currentUserId;
+ final senderName = message.senderId == null
+ ? 'System'
+ : profileById[message.senderId]?.fullName ?? message.senderId!;
+ final bubbleColor = isMe
+ ? Theme.of(context).colorScheme.primaryContainer
+ : Theme.of(context).colorScheme.surfaceContainerHighest;
+ final textColor = isMe
+ ? Theme.of(context).colorScheme.onPrimaryContainer
+ : Theme.of(context).colorScheme.onSurface;
+
+ return Align(
+ alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
+ child: Column(
+ crossAxisAlignment: isMe
+ ? CrossAxisAlignment.end
+ : CrossAxisAlignment.start,
+ children: [
+ if (!isMe)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 4),
+ child: Text(
+ senderName,
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ ),
+ Container(
+ margin: const EdgeInsets.only(bottom: 12),
+ padding: const EdgeInsets.all(12),
+ constraints: const BoxConstraints(maxWidth: 520),
+ decoration: BoxDecoration(
+ color: bubbleColor,
+ borderRadius: BorderRadius.only(
+ topLeft: const Radius.circular(16),
+ topRight: const Radius.circular(16),
+ bottomLeft: Radius.circular(isMe ? 16 : 4),
+ bottomRight: Radius.circular(isMe ? 4 : 16),
+ ),
+ ),
+ child: _buildMentionText(message.content, textColor, profiles),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildTatSection(Task task) {
+ final animateQueue = task.status == 'queued';
+ final animateExecution = task.startedAt != null && task.completedAt == null;
+
+ if (!animateQueue && !animateExecution) {
+ return _buildTatContent(task, DateTime.now());
+ }
+
+ return StreamBuilder(
+ stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick),
+ builder: (context, snapshot) {
+ return _buildTatContent(task, DateTime.now());
+ },
+ );
+ }
+
+ Widget _buildTatContent(Task task, DateTime now) {
+ final queueDuration = task.status == 'queued'
+ ? now.difference(task.createdAt)
+ : _safeDuration(task.startedAt?.difference(task.createdAt));
+ final executionDuration = task.status == 'queued'
+ ? null
+ : task.startedAt == null
+ ? null
+ : task.completedAt == null
+ ? now.difference(task.startedAt!)
+ : task.completedAt!.difference(task.startedAt!);
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Queue duration: ${_formatDuration(queueDuration)}'),
+ const SizedBox(height: 8),
+ Text('Task execution time: ${_formatDuration(executionDuration)}'),
+ ],
+ );
+ }
+
+ Duration? _safeDuration(Duration? duration) {
+ if (duration == null) {
+ return null;
+ }
+ return duration.isNegative ? Duration.zero : duration;
+ }
+
+ String _formatDuration(Duration? duration) {
+ if (duration == null) {
+ return 'Pending';
+ }
+ if (duration.inSeconds < 60) {
+ return 'Less than a minute';
+ }
+ final hours = duration.inHours;
+ final minutes = duration.inMinutes.remainder(60);
+ if (hours > 0) {
+ return '${hours}h ${minutes}m';
+ }
+ return '${minutes}m';
+ }
+
+ Widget _buildMentionText(
+ String text,
+ Color baseColor,
+ List profiles,
+ ) {
+ final mentionColor = Theme.of(context).colorScheme.primary;
+ final spans = _mentionSpans(text, baseColor, mentionColor, profiles);
+ return RichText(
+ text: TextSpan(
+ children: spans,
+ style: TextStyle(color: baseColor),
+ ),
+ );
+ }
+
+ List _mentionSpans(
+ String text,
+ Color baseColor,
+ Color mentionColor,
+ List profiles,
+ ) {
+ final mentionLabels = profiles
+ .map(
+ (profile) => profile.fullName.isEmpty ? profile.id : profile.fullName,
+ )
+ .where((label) => label.isNotEmpty)
+ .map(_escapeRegExp)
+ .toList();
+ final pattern = mentionLabels.isEmpty
+ ? r'@\S+'
+ : '@(?:${mentionLabels.join('|')})';
+ final matches = RegExp(pattern, caseSensitive: false).allMatches(text);
+ if (matches.isEmpty) {
+ return [
+ TextSpan(
+ text: text,
+ style: TextStyle(color: baseColor),
+ ),
+ ];
+ }
+
+ final spans = [];
+ var lastIndex = 0;
+ for (final match in matches) {
+ if (match.start > lastIndex) {
+ spans.add(
+ TextSpan(
+ text: text.substring(lastIndex, match.start),
+ style: TextStyle(color: baseColor),
+ ),
+ );
+ }
+ spans.add(
+ TextSpan(
+ text: text.substring(match.start, match.end),
+ style: TextStyle(color: mentionColor, fontWeight: FontWeight.w700),
+ ),
+ );
+ lastIndex = match.end;
+ }
+ if (lastIndex < text.length) {
+ spans.add(
+ TextSpan(
+ text: text.substring(lastIndex),
+ style: TextStyle(color: baseColor),
+ ),
+ );
+ }
+ return spans;
+ }
+
+ String _escapeRegExp(String value) {
+ return value.replaceAllMapped(
+ RegExp(r'[\\^$.*+?()[\]{}|]'),
+ (match) => '\\${match[0]}',
+ );
+ }
+
+ AsyncValue> _mergeMessages(
+ AsyncValue> taskMessages,
+ AsyncValue>? ticketMessages,
+ ) {
+ if (ticketMessages == null) {
+ return taskMessages;
+ }
+ return taskMessages.when(
+ data: (taskData) => ticketMessages.when(
+ data: (ticketData) {
+ final byId = {
+ for (final message in taskData) message.id: message,
+ for (final message in ticketData) message.id: message,
+ };
+ final merged = byId.values.toList()
+ ..sort((a, b) => b.createdAt.compareTo(a.createdAt));
+ return AsyncValue.data(merged);
+ },
+ loading: () => const AsyncLoading>(),
+ error: (error, stackTrace) =>
+ AsyncError>(error, stackTrace),
+ ),
+ loading: () => const AsyncLoading>(),
+ error: (error, stackTrace) =>
+ AsyncError>(error, stackTrace),
+ );
+ }
+
+ Future _handleSendMessage(
+ Task task,
+ List profiles,
+ String? currentUserId,
+ bool canSendMessages,
+ String typingChannelId,
+ ) async {
+ if (!canSendMessages) return;
+ final content = _messageController.text.trim();
+ if (content.isEmpty) {
+ return;
+ }
+ ref.read(typingIndicatorProvider(typingChannelId).notifier).stopTyping();
+ final message = await ref
+ .read(ticketsControllerProvider)
+ .sendTaskMessage(
+ taskId: task.id,
+ ticketId: task.ticketId,
+ content: content,
+ );
+ final mentionUserIds = _extractMentionedUserIds(
+ content,
+ profiles,
+ currentUserId,
+ );
+ if (mentionUserIds.isNotEmpty && currentUserId != null) {
+ await ref
+ .read(notificationsControllerProvider)
+ .createMentionNotifications(
+ userIds: mentionUserIds,
+ actorId: currentUserId,
+ ticketId: task.ticketId,
+ taskId: task.id,
+ messageId: message.id,
+ );
+ }
+ ref.invalidate(taskMessagesProvider(task.id));
+ if (task.ticketId != null) {
+ ref.invalidate(ticketMessagesProvider(task.ticketId!));
+ }
+ if (mounted) {
+ _messageController.clear();
+ _clearMentions();
+ }
+ }
+
+ void _handleComposerChanged(
+ List profiles,
+ String? currentUserId,
+ bool canSendMessages,
+ String typingChannelId,
+ ) {
+ if (!canSendMessages) {
+ ref.read(typingIndicatorProvider(typingChannelId).notifier).stopTyping();
+ _clearMentions();
+ return;
+ }
+ ref.read(typingIndicatorProvider(typingChannelId).notifier).userTyping();
+ final text = _messageController.text;
+ final cursor = _messageController.selection.baseOffset;
+ if (cursor < 0) {
+ _clearMentions();
+ return;
+ }
+ final textBeforeCursor = text.substring(0, cursor);
+ final atIndex = textBeforeCursor.lastIndexOf('@');
+ if (atIndex == -1) {
+ _clearMentions();
+ return;
+ }
+ if (atIndex > 0 && !_isWhitespace(textBeforeCursor[atIndex - 1])) {
+ _clearMentions();
+ return;
+ }
+ final query = textBeforeCursor.substring(atIndex + 1);
+ if (query.contains(RegExp(r'\s'))) {
+ _clearMentions();
+ return;
+ }
+ final normalizedQuery = query.toLowerCase();
+ final candidates = profiles.where((profile) {
+ if (profile.id == currentUserId) {
+ return false;
+ }
+ final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
+ return label.toLowerCase().contains(normalizedQuery);
+ }).toList();
+ setState(() {
+ _mentionQuery = query;
+ _mentionStart = atIndex;
+ _mentionResults = candidates.take(6).toList();
+ });
+ }
+
+ void _clearMentions() {
+ if (_mentionQuery == null && _mentionResults.isEmpty) {
+ return;
+ }
+ setState(() {
+ _mentionQuery = null;
+ _mentionStart = null;
+ _mentionResults = [];
+ });
+ }
+
+ bool _isWhitespace(String char) {
+ return char.trim().isEmpty;
+ }
+
+ List _extractMentionedUserIds(
+ String content,
+ List profiles,
+ String? currentUserId,
+ ) {
+ final lower = content.toLowerCase();
+ final mentioned = {};
+ for (final profile in profiles) {
+ if (profile.id == currentUserId) continue;
+ final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
+ if (label.isEmpty) continue;
+ final token = '@${label.toLowerCase()}';
+ if (lower.contains(token)) {
+ mentioned.add(profile.id);
+ }
+ }
+ return mentioned.toList();
+ }
+
+ Widget _buildMentionList(AsyncValue> profilesAsync) {
+ if (_mentionResults.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ return Container(
+ constraints: const BoxConstraints(maxHeight: 200),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: ListView.separated(
+ shrinkWrap: true,
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ itemCount: _mentionResults.length,
+ separatorBuilder: (context, index) => const SizedBox(height: 4),
+ itemBuilder: (context, index) {
+ final profile = _mentionResults[index];
+ final label = profile.fullName.isEmpty
+ ? profile.id
+ : profile.fullName;
+ return ListTile(
+ dense: true,
+ title: Text(label),
+ onTap: () => _insertMention(profile),
+ );
+ },
+ ),
+ );
+ }
+
+ void _insertMention(Profile profile) {
+ final start = _mentionStart;
+ if (start == null) {
+ _clearMentions();
+ return;
+ }
+ final text = _messageController.text;
+ final cursor = _messageController.selection.baseOffset;
+ final end = cursor < 0 ? text.length : cursor;
+ final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
+ final mentionText = '@$label ';
+ final updated = text.replaceRange(start, end, mentionText);
+ final newCursor = start + mentionText.length;
+ _messageController.text = updated;
+ _messageController.selection = TextSelection.collapsed(offset: newCursor);
+ _clearMentions();
+ }
+
+ String _typingLabel(
+ Set userIds,
+ AsyncValue> profilesAsync,
+ ) {
+ final profileById = {
+ for (final profile in profilesAsync.valueOrNull ?? [])
+ profile.id: profile,
+ };
+ final names = userIds
+ .map((id) => profileById[id]?.fullName ?? id)
+ .where((name) => name.isNotEmpty)
+ .toList();
+ if (names.isEmpty) {
+ return 'Someone is typing...';
+ }
+ if (names.length == 1) {
+ return '${names.first} is typing...';
+ }
+ if (names.length == 2) {
+ return '${names[0]} and ${names[1]} are typing...';
+ }
+ return '${names[0]}, ${names[1]} and others are typing...';
+ }
+
+ Task? _findTask(AsyncValue> tasksAsync, String taskId) {
+ return tasksAsync.maybeWhen(
+ data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull,
+ orElse: () => null,
+ );
+ }
+
+ Ticket? _findTicket(AsyncValue> ticketsAsync, String ticketId) {
+ return ticketsAsync.maybeWhen(
+ data: (tickets) =>
+ tickets.where((ticket) => ticket.id == ticketId).firstOrNull,
+ orElse: () => null,
+ );
+ }
+
+ bool _canAssignStaff(String role) {
+ return role == 'admin' || role == 'dispatcher' || role == 'it_staff';
+ }
+
+ Widget _buildStatusChip(
+ BuildContext context,
+ Task task,
+ bool canUpdateStatus,
+ ) {
+ final chip = Chip(
+ label: Text(task.status.toUpperCase()),
+ backgroundColor: _statusColor(context, task.status),
+ labelStyle: TextStyle(
+ color: _statusTextColor(context, task.status),
+ fontWeight: FontWeight.w600,
+ ),
+ );
+
+ if (!canUpdateStatus) {
+ return chip;
+ }
+
+ return PopupMenuButton(
+ onSelected: (value) async {
+ await ref
+ .read(tasksControllerProvider)
+ .updateTaskStatus(taskId: task.id, status: value);
+ ref.invalidate(tasksProvider);
+ },
+ itemBuilder: (context) => _statusOptions
+ .map(
+ (status) => PopupMenuItem(
+ value: status,
+ child: Text(_statusMenuLabel(status)),
+ ),
+ )
+ .toList(),
+ child: chip,
+ );
+ }
+
+ String _statusMenuLabel(String status) {
+ return switch (status) {
+ 'queued' => 'Queued',
+ 'in_progress' => 'In progress',
+ 'completed' => 'Completed',
+ _ => status,
+ };
+ }
+
+ Color _statusColor(BuildContext context, String status) {
+ return switch (status) {
+ 'queued' => Colors.blueGrey.shade200,
+ 'in_progress' => Colors.blue.shade300,
+ 'completed' => Colors.green.shade300,
+ _ => Theme.of(context).colorScheme.surfaceContainerHighest,
+ };
+ }
+
+ Color _statusTextColor(BuildContext context, String status) {
+ return switch (status) {
+ 'queued' => Colors.blueGrey.shade900,
+ 'in_progress' => Colors.blue.shade900,
+ 'completed' => Colors.green.shade900,
+ _ => Theme.of(context).colorScheme.onSurfaceVariant,
+ };
+ }
+
+ bool _canUpdateStatus(
+ Profile? profile,
+ List assignments,
+ String taskId,
+ ) {
+ if (profile == null) {
+ return false;
+ }
+ final isGlobal =
+ profile.role == 'admin' ||
+ profile.role == 'dispatcher' ||
+ profile.role == 'it_staff';
+ if (isGlobal) {
+ return true;
+ }
+ return assignments.any(
+ (assignment) =>
+ assignment.taskId == taskId && assignment.userId == profile.id,
+ );
+ }
+}
+
+extension _FirstOrNull on Iterable {
+ T? get firstOrNull => isEmpty ? null : first;
+}
diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart
new file mode 100644
index 00000000..45b360e7
--- /dev/null
+++ b/lib/screens/tasks/tasks_list_screen.dart
@@ -0,0 +1,319 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../models/notification_item.dart';
+import '../../models/task.dart';
+import '../../providers/notifications_provider.dart';
+import '../../providers/profile_provider.dart';
+import '../../providers/tasks_provider.dart';
+import '../../providers/tickets_provider.dart';
+import '../../providers/typing_provider.dart';
+import '../../widgets/responsive_body.dart';
+import '../../widgets/typing_dots.dart';
+
+class TasksListScreen extends ConsumerWidget {
+ const TasksListScreen({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final tasksAsync = ref.watch(tasksProvider);
+ final ticketsAsync = ref.watch(ticketsProvider);
+ final officesAsync = ref.watch(officesProvider);
+ final profileAsync = ref.watch(currentProfileProvider);
+ final notificationsAsync = ref.watch(notificationsProvider);
+
+ final canCreate = profileAsync.maybeWhen(
+ data: (profile) =>
+ profile != null &&
+ (profile.role == 'admin' ||
+ profile.role == 'dispatcher' ||
+ profile.role == 'it_staff'),
+ orElse: () => false,
+ );
+
+ final ticketById = {
+ for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket,
+ };
+ final officeById = {
+ for (final office in officesAsync.valueOrNull ?? []) office.id: office,
+ };
+
+ return Scaffold(
+ body: ResponsiveBody(
+ child: tasksAsync.when(
+ data: (tasks) {
+ if (tasks.isEmpty) {
+ return const Center(child: Text('No tasks yet.'));
+ }
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(top: 16, bottom: 8),
+ child: Align(
+ alignment: Alignment.center,
+ child: Text(
+ 'Tasks',
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: ListView.separated(
+ padding: const EdgeInsets.only(bottom: 24),
+ itemCount: tasks.length,
+ separatorBuilder: (context, index) =>
+ const SizedBox(height: 12),
+ itemBuilder: (context, index) {
+ final task = tasks[index];
+ final ticketId = task.ticketId;
+ final ticket = ticketId == null
+ ? null
+ : ticketById[ticketId];
+ final officeId = ticket?.officeId ?? task.officeId;
+ final officeName = officeId == null
+ ? 'Unassigned office'
+ : (officeById[officeId]?.name ?? officeId);
+ final subtitle = _buildSubtitle(officeName, task.status);
+ final hasMention = _hasTaskMention(
+ notificationsAsync,
+ task,
+ );
+ final typingChannelId = task.id;
+ final typingState = ref.watch(
+ typingIndicatorProvider(typingChannelId),
+ );
+ final showTyping = typingState.userIds.isNotEmpty;
+
+ return ListTile(
+ leading: _buildQueueBadge(context, task),
+ title: Text(
+ task.title.isNotEmpty
+ ? task.title
+ : (ticket?.subject ?? 'Task ${task.id}'),
+ ),
+ subtitle: Text(subtitle),
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _buildStatusChip(context, task.status),
+ if (showTyping) ...[
+ const SizedBox(width: 6),
+ TypingDots(
+ size: 6,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ ],
+ if (hasMention)
+ const Padding(
+ padding: EdgeInsets.only(left: 8),
+ child: Icon(
+ Icons.circle,
+ size: 10,
+ color: Colors.red,
+ ),
+ ),
+ ],
+ ),
+ onTap: () => context.go('/tasks/${task.id}'),
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ },
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) =>
+ Center(child: Text('Failed to load tasks: $error')),
+ ),
+ ),
+ floatingActionButton: canCreate
+ ? FloatingActionButton.extended(
+ onPressed: () => _showCreateTaskDialog(context, ref),
+ icon: const Icon(Icons.add),
+ label: const Text('New Task'),
+ )
+ : null,
+ );
+ }
+
+ Future _showCreateTaskDialog(
+ BuildContext context,
+ WidgetRef ref,
+ ) async {
+ final titleController = TextEditingController();
+ final descriptionController = TextEditingController();
+ String? selectedOfficeId;
+
+ await showDialog(
+ context: context,
+ builder: (dialogContext) {
+ return StatefulBuilder(
+ builder: (context, setState) {
+ final officesAsync = ref.watch(officesProvider);
+ return AlertDialog(
+ title: const Text('Create Task'),
+ content: SizedBox(
+ width: 360,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: titleController,
+ decoration: const InputDecoration(
+ labelText: 'Task title',
+ ),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: descriptionController,
+ decoration: const InputDecoration(
+ labelText: 'Description',
+ ),
+ maxLines: 3,
+ ),
+ const SizedBox(height: 12),
+ officesAsync.when(
+ data: (offices) {
+ if (offices.isEmpty) {
+ return const Text('No offices available.');
+ }
+ selectedOfficeId ??= offices.first.id;
+ return DropdownButtonFormField(
+ initialValue: selectedOfficeId,
+ decoration: const InputDecoration(
+ labelText: 'Office',
+ ),
+ items: offices
+ .map(
+ (office) => DropdownMenuItem(
+ value: office.id,
+ child: Text(office.name),
+ ),
+ )
+ .toList(),
+ onChanged: (value) =>
+ setState(() => selectedOfficeId = value),
+ );
+ },
+ loading: () => const Align(
+ alignment: Alignment.centerLeft,
+ child: CircularProgressIndicator(),
+ ),
+ error: (error, _) =>
+ Text('Failed to load offices: $error'),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () async {
+ final title = titleController.text.trim();
+ final description = descriptionController.text.trim();
+ final officeId = selectedOfficeId;
+ if (title.isEmpty || officeId == null) {
+ return;
+ }
+ await ref
+ .read(tasksControllerProvider)
+ .createTask(
+ title: title,
+ description: description,
+ officeId: officeId,
+ );
+ if (context.mounted) {
+ Navigator.of(dialogContext).pop();
+ }
+ },
+ child: const Text('Create'),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ );
+ }
+
+ bool _hasTaskMention(
+ AsyncValue> notificationsAsync,
+ Task task,
+ ) {
+ return notificationsAsync.maybeWhen(
+ data: (items) => items.any(
+ (item) =>
+ item.isUnread &&
+ (item.taskId == task.id || item.ticketId == task.ticketId),
+ ),
+ orElse: () => false,
+ );
+ }
+
+ Widget _buildQueueBadge(BuildContext context, Task task) {
+ final queueOrder = task.queueOrder;
+ final isQueued = task.status == 'queued';
+ if (!isQueued || queueOrder == null) {
+ return const Icon(Icons.fact_check_outlined);
+ }
+ return Container(
+ width: 40,
+ height: 40,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primaryContainer,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ '#$queueOrder',
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: Theme.of(context).colorScheme.onPrimaryContainer,
+ ),
+ ),
+ );
+ }
+
+ String _buildSubtitle(String officeName, String status) {
+ final statusLabel = status.toUpperCase();
+ return '$officeName · $statusLabel';
+ }
+
+ Widget _buildStatusChip(BuildContext context, String status) {
+ return Chip(
+ label: Text(status.toUpperCase()),
+ backgroundColor: _statusColor(context, status),
+ labelStyle: TextStyle(
+ color: _statusTextColor(context, status),
+ fontWeight: FontWeight.w600,
+ ),
+ );
+ }
+
+ Color _statusColor(BuildContext context, String status) {
+ return switch (status) {
+ 'queued' => Colors.blueGrey.shade200,
+ 'in_progress' => Colors.blue.shade300,
+ 'completed' => Colors.green.shade300,
+ _ => Theme.of(context).colorScheme.surfaceContainerHighest,
+ };
+ }
+
+ Color _statusTextColor(BuildContext context, String status) {
+ return switch (status) {
+ 'queued' => Colors.blueGrey.shade900,
+ 'in_progress' => Colors.blue.shade900,
+ 'completed' => Colors.green.shade900,
+ _ => Theme.of(context).colorScheme.onSurfaceVariant,
+ };
+ }
+}
diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart
new file mode 100644
index 00000000..8356d628
--- /dev/null
+++ b/lib/screens/tickets/ticket_detail_screen.dart
@@ -0,0 +1,859 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import '../../models/office.dart';
+import '../../models/profile.dart';
+import '../../models/task.dart';
+import '../../models/ticket.dart';
+import '../../models/ticket_message.dart';
+import '../../providers/notifications_provider.dart';
+import '../../providers/profile_provider.dart';
+import '../../providers/tasks_provider.dart';
+import '../../providers/tickets_provider.dart';
+import '../../providers/typing_provider.dart';
+import '../../widgets/responsive_body.dart';
+import '../../widgets/task_assignment_section.dart';
+import '../../widgets/typing_dots.dart';
+
+class TicketDetailScreen extends ConsumerStatefulWidget {
+ const TicketDetailScreen({super.key, required this.ticketId});
+
+ final String ticketId;
+
+ @override
+ ConsumerState createState() => _TicketDetailScreenState();
+}
+
+class _TicketDetailScreenState extends ConsumerState {
+ final _messageController = TextEditingController();
+ static const List _statusOptions = ['pending', 'promoted', 'closed'];
+ String? _mentionQuery;
+ int? _mentionStart;
+ List _mentionResults = [];
+
+ @override
+ void initState() {
+ super.initState();
+ Future.microtask(
+ () => ref
+ .read(notificationsControllerProvider)
+ .markReadForTicket(widget.ticketId),
+ );
+ }
+
+ @override
+ void dispose() {
+ _messageController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final ticket = _findTicket(ref, widget.ticketId);
+ final messagesAsync = ref.watch(ticketMessagesProvider(widget.ticketId));
+ final profilesAsync = ref.watch(profilesProvider);
+ final officesAsync = ref.watch(officesProvider);
+ final currentProfileAsync = ref.watch(currentProfileProvider);
+ final tasksAsync = ref.watch(tasksProvider);
+ final typingState = ref.watch(typingIndicatorProvider(widget.ticketId));
+ final canPromote = currentProfileAsync.maybeWhen(
+ data: (profile) => profile != null && _canPromote(profile.role),
+ orElse: () => false,
+ );
+ final canSendMessages = ticket != null && ticket.status != 'closed';
+ final canAssign = currentProfileAsync.maybeWhen(
+ data: (profile) => profile != null && _canAssignStaff(profile.role),
+ orElse: () => false,
+ );
+ final showAssign = canAssign && ticket?.status != 'closed';
+ final taskForTicket = ticket == null
+ ? null
+ : _findTaskForTicket(tasksAsync, ticket.id);
+ final hasStaffMessage = _hasStaffMessage(
+ messagesAsync.valueOrNull ?? const [],
+ profilesAsync.valueOrNull ?? const [],
+ );
+ final effectiveRespondedAt = ticket?.promotedAt != null && !hasStaffMessage
+ ? ticket!.promotedAt
+ : ticket?.respondedAt;
+
+ return ResponsiveBody(
+ child: Column(
+ children: [
+ if (ticket != null)
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Align(
+ alignment: Alignment.center,
+ child: Text(
+ ticket.subject,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ const SizedBox(height: 6),
+ Align(
+ alignment: Alignment.center,
+ child: Text(
+ _filedByLabel(profilesAsync, ticket),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 12,
+ runSpacing: 8,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ _buildStatusChip(context, ref, ticket, canPromote),
+ Text('Office: ${_officeLabel(officesAsync, ticket)}'),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Text(ticket.description),
+ const SizedBox(height: 12),
+ _buildTatRow(context, ticket, effectiveRespondedAt),
+ if (taskForTicket != null) ...[
+ const SizedBox(height: 16),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: TaskAssignmentSection(
+ taskId: taskForTicket.id,
+ canAssign: showAssign,
+ ),
+ ),
+ const SizedBox(width: 8),
+ IconButton(
+ tooltip: 'Open task',
+ onPressed: () =>
+ context.go('/tasks/${taskForTicket.id}'),
+ icon: const Icon(Icons.open_in_new),
+ ),
+ ],
+ ),
+ ],
+ ],
+ ),
+ ),
+ const Divider(height: 1),
+ Expanded(
+ child: messagesAsync.when(
+ data: (messages) {
+ if (messages.isEmpty) {
+ return const Center(child: Text('No messages yet.'));
+ }
+ final profileById = {
+ for (final profile in profilesAsync.valueOrNull ?? [])
+ profile.id: profile,
+ };
+ return ListView.builder(
+ reverse: true,
+ padding: const EdgeInsets.fromLTRB(0, 16, 0, 72),
+ itemCount: messages.length,
+ itemBuilder: (context, index) {
+ final message = messages[index];
+ final currentUserId =
+ Supabase.instance.client.auth.currentUser?.id;
+ final isMe =
+ currentUserId != null &&
+ message.senderId == currentUserId;
+ final senderName = message.senderId == null
+ ? 'System'
+ : profileById[message.senderId]?.fullName ??
+ message.senderId!;
+ final bubbleColor = isMe
+ ? Theme.of(context).colorScheme.primaryContainer
+ : Theme.of(context).colorScheme.surfaceContainerHighest;
+ final textColor = isMe
+ ? Theme.of(context).colorScheme.onPrimaryContainer
+ : Theme.of(context).colorScheme.onSurface;
+
+ return Align(
+ alignment: isMe
+ ? Alignment.centerRight
+ : Alignment.centerLeft,
+ child: Column(
+ crossAxisAlignment: isMe
+ ? CrossAxisAlignment.end
+ : CrossAxisAlignment.start,
+ children: [
+ if (!isMe)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 4),
+ child: Text(
+ senderName,
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ ),
+ Container(
+ margin: const EdgeInsets.only(bottom: 12),
+ padding: const EdgeInsets.all(12),
+ constraints: const BoxConstraints(maxWidth: 520),
+ decoration: BoxDecoration(
+ color: bubbleColor,
+ borderRadius: BorderRadius.only(
+ topLeft: const Radius.circular(16),
+ topRight: const Radius.circular(16),
+ bottomLeft: Radius.circular(isMe ? 16 : 4),
+ bottomRight: Radius.circular(isMe ? 4 : 16),
+ ),
+ ),
+ child: _buildMentionText(
+ message.content,
+ textColor,
+ profilesAsync.valueOrNull ?? [],
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ },
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) =>
+ Center(child: Text('Failed to load messages: $error')),
+ ),
+ ),
+ SafeArea(
+ top: false,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(0, 8, 0, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (typingState.userIds.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 6),
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 6,
+ ),
+ decoration: BoxDecoration(
+ color: Theme.of(
+ context,
+ ).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ _typingLabel(typingState.userIds, profilesAsync),
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ const SizedBox(width: 8),
+ TypingDots(
+ size: 8,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (_mentionQuery != null)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: _buildMentionList(profilesAsync),
+ ),
+ if (!canSendMessages)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Text(
+ 'Messaging is disabled for closed tickets.',
+ style: Theme.of(context).textTheme.labelMedium,
+ ),
+ ),
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _messageController,
+ decoration: const InputDecoration(
+ hintText: 'Message...',
+ ),
+ enabled: canSendMessages,
+ textInputAction: TextInputAction.send,
+ onChanged: canSendMessages
+ ? (_) => _handleComposerChanged(
+ profilesAsync.valueOrNull ?? [],
+ Supabase.instance.client.auth.currentUser?.id,
+ canSendMessages,
+ )
+ : null,
+ onSubmitted: canSendMessages
+ ? (_) => _handleSendMessage(
+ ref,
+ profilesAsync.valueOrNull ?? [],
+ Supabase.instance.client.auth.currentUser?.id,
+ canSendMessages,
+ )
+ : null,
+ ),
+ ),
+ const SizedBox(width: 12),
+ IconButton(
+ tooltip: 'Send',
+ onPressed: canSendMessages
+ ? () => _handleSendMessage(
+ ref,
+ profilesAsync.valueOrNull ?? [],
+ Supabase.instance.client.auth.currentUser?.id,
+ canSendMessages,
+ )
+ : null,
+ icon: const Icon(Icons.send),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ String _filedByLabel(AsyncValue> profilesAsync, Ticket ticket) {
+ final creatorId = ticket.creatorId;
+ if (creatorId == null || creatorId.isEmpty) {
+ return 'Filed by: Unknown';
+ }
+ final profile = profilesAsync.valueOrNull
+ ?.where((item) => item.id == creatorId)
+ .firstOrNull;
+ final name = profile?.fullName.isNotEmpty == true
+ ? profile!.fullName
+ : creatorId;
+ return 'Filed by: $name';
+ }
+
+ Ticket? _findTicket(WidgetRef ref, String ticketId) {
+ final ticketsAsync = ref.watch(ticketsProvider);
+ return ticketsAsync.maybeWhen(
+ data: (tickets) =>
+ tickets.where((ticket) => ticket.id == ticketId).firstOrNull,
+ orElse: () => null,
+ );
+ }
+
+ bool _hasStaffMessage(List messages, List profiles) {
+ if (messages.isEmpty || profiles.isEmpty) {
+ return false;
+ }
+ final staffIds = profiles
+ .where(
+ (profile) =>
+ profile.role == 'admin' ||
+ profile.role == 'dispatcher' ||
+ profile.role == 'it_staff',
+ )
+ .map((profile) => profile.id)
+ .toSet();
+ if (staffIds.isEmpty) {
+ return false;
+ }
+ return messages.any(
+ (message) =>
+ message.senderId != null && staffIds.contains(message.senderId!),
+ );
+ }
+
+ Task? _findTaskForTicket(AsyncValue> tasksAsync, String ticketId) {
+ return tasksAsync.maybeWhen(
+ data: (tasks) =>
+ tasks.where((task) => task.ticketId == ticketId).firstOrNull,
+ orElse: () => null,
+ );
+ }
+
+ Future _handleSendMessage(
+ WidgetRef ref,
+ List profiles,
+ String? currentUserId,
+ bool canSendMessages,
+ ) async {
+ if (!canSendMessages) return;
+ final content = _messageController.text.trim();
+ if (content.isEmpty) return;
+ ref.read(typingIndicatorProvider(widget.ticketId).notifier).stopTyping();
+ final message = await ref
+ .read(ticketsControllerProvider)
+ .sendTicketMessage(ticketId: widget.ticketId, content: content);
+ final mentionUserIds = _extractMentionedUserIds(
+ content,
+ profiles,
+ currentUserId,
+ );
+ if (mentionUserIds.isNotEmpty && currentUserId != null) {
+ await ref
+ .read(notificationsControllerProvider)
+ .createMentionNotifications(
+ userIds: mentionUserIds,
+ actorId: currentUserId,
+ ticketId: widget.ticketId,
+ messageId: message.id,
+ );
+ }
+ ref.invalidate(ticketMessagesProvider(widget.ticketId));
+ if (mounted) {
+ _messageController.clear();
+ _clearMentions();
+ }
+ }
+
+ List _extractMentionedUserIds(
+ String content,
+ List profiles,
+ String? currentUserId,
+ ) {
+ final lower = content.toLowerCase();
+ final mentioned = {};
+ for (final profile in profiles) {
+ if (profile.id == currentUserId) continue;
+ final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
+ if (label.isEmpty) continue;
+ final token = '@${label.toLowerCase()}';
+ if (lower.contains(token)) {
+ mentioned.add(profile.id);
+ }
+ }
+ return mentioned.toList();
+ }
+
+ void _handleComposerChanged(
+ List profiles,
+ String? currentUserId,
+ bool canSendMessages,
+ ) {
+ if (!canSendMessages) {
+ ref.read(typingIndicatorProvider(widget.ticketId).notifier).stopTyping();
+ _clearMentions();
+ return;
+ }
+ ref.read(typingIndicatorProvider(widget.ticketId).notifier).userTyping();
+ final text = _messageController.text;
+ final cursor = _messageController.selection.baseOffset;
+ if (cursor < 0) {
+ _clearMentions();
+ return;
+ }
+ final textBeforeCursor = text.substring(0, cursor);
+ final atIndex = textBeforeCursor.lastIndexOf('@');
+ if (atIndex == -1) {
+ _clearMentions();
+ return;
+ }
+ if (atIndex > 0 && !_isWhitespace(textBeforeCursor[atIndex - 1])) {
+ _clearMentions();
+ return;
+ }
+ final query = textBeforeCursor.substring(atIndex + 1);
+ if (query.contains(RegExp(r'\s'))) {
+ _clearMentions();
+ return;
+ }
+ final normalizedQuery = query.toLowerCase();
+ final candidates = profiles.where((profile) {
+ if (profile.id == currentUserId) {
+ return false;
+ }
+ final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
+ return label.toLowerCase().contains(normalizedQuery);
+ }).toList();
+ setState(() {
+ _mentionQuery = query;
+ _mentionStart = atIndex;
+ _mentionResults = candidates.take(6).toList();
+ });
+ }
+
+ void _clearMentions() {
+ if (_mentionQuery == null && _mentionResults.isEmpty) {
+ return;
+ }
+ setState(() {
+ _mentionQuery = null;
+ _mentionStart = null;
+ _mentionResults = [];
+ });
+ }
+
+ bool _isWhitespace(String char) {
+ return char.trim().isEmpty;
+ }
+
+ String _typingLabel(
+ Set userIds,
+ AsyncValue> profilesAsync,
+ ) {
+ final profileById = {
+ for (final profile in profilesAsync.valueOrNull ?? [])
+ profile.id: profile,
+ };
+ final names = userIds
+ .map((id) => profileById[id]?.fullName ?? id)
+ .where((name) => name.isNotEmpty)
+ .toList();
+ if (names.isEmpty) {
+ return 'Someone is typing...';
+ }
+ if (names.length == 1) {
+ return '${names.first} is typing...';
+ }
+ if (names.length == 2) {
+ return '${names[0]} and ${names[1]} are typing...';
+ }
+ return '${names[0]}, ${names[1]} and others are typing...';
+ }
+
+ Widget _buildMentionList(AsyncValue> profilesAsync) {
+ if (_mentionResults.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ return Container(
+ constraints: const BoxConstraints(maxHeight: 200),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: ListView.separated(
+ shrinkWrap: true,
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ itemCount: _mentionResults.length,
+ separatorBuilder: (context, index) => const SizedBox(height: 4),
+ itemBuilder: (context, index) {
+ final profile = _mentionResults[index];
+ final label = profile.fullName.isEmpty
+ ? profile.id
+ : profile.fullName;
+ return ListTile(
+ dense: true,
+ title: Text(label),
+ onTap: () => _insertMention(profile),
+ );
+ },
+ ),
+ );
+ }
+
+ void _insertMention(Profile profile) {
+ final start = _mentionStart;
+ if (start == null) {
+ _clearMentions();
+ return;
+ }
+ final text = _messageController.text;
+ final cursor = _messageController.selection.baseOffset;
+ final end = cursor < 0 ? text.length : cursor;
+ final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
+ final mentionText = '@$label ';
+ final updated = text.replaceRange(start, end, mentionText);
+ final newCursor = start + mentionText.length;
+ _messageController.text = updated;
+ _messageController.selection = TextSelection.collapsed(offset: newCursor);
+ _clearMentions();
+ }
+
+ Widget _buildTatRow(
+ BuildContext context,
+ Ticket ticket,
+ DateTime? respondedAtOverride,
+ ) {
+ final respondedAt = respondedAtOverride ?? ticket.respondedAt;
+ final responseDuration = respondedAt?.difference(ticket.createdAt);
+ final triageEnd = _earliestDate(ticket.promotedAt, ticket.closedAt);
+ final triageStart = respondedAt;
+ final triageDuration = triageStart == null || triageEnd == null
+ ? null
+ : triageEnd.difference(triageStart);
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Response time: ${responseDuration == null ? 'Pending' : _formatDuration(responseDuration)}',
+ ),
+ const SizedBox(height: 8),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Expanded(
+ child: Text(
+ 'Triage duration: ${triageDuration == null ? 'Pending' : _formatDuration(triageDuration)}',
+ ),
+ ),
+ IconButton(
+ tooltip: 'View timeline',
+ onPressed: () => _showTimelineDialog(context, ticket),
+ icon: const Icon(Icons.access_time),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildMentionText(
+ String text,
+ Color baseColor,
+ List profiles,
+ ) {
+ final mentionColor = Theme.of(context).colorScheme.primary;
+ final spans = _mentionSpans(text, baseColor, mentionColor, profiles);
+ return RichText(
+ text: TextSpan(
+ children: spans,
+ style: TextStyle(color: baseColor),
+ ),
+ );
+ }
+
+ List _mentionSpans(
+ String text,
+ Color baseColor,
+ Color mentionColor,
+ List profiles,
+ ) {
+ final mentionLabels = profiles
+ .map(
+ (profile) => profile.fullName.isEmpty ? profile.id : profile.fullName,
+ )
+ .where((label) => label.isNotEmpty)
+ .map(_escapeRegExp)
+ .toList();
+ final pattern = mentionLabels.isEmpty
+ ? r'@\S+'
+ : '@(?:${mentionLabels.join('|')})';
+ final matches = RegExp(pattern, caseSensitive: false).allMatches(text);
+ if (matches.isEmpty) {
+ return [
+ TextSpan(
+ text: text,
+ style: TextStyle(color: baseColor),
+ ),
+ ];
+ }
+
+ final spans = [];
+ var lastIndex = 0;
+ for (final match in matches) {
+ if (match.start > lastIndex) {
+ spans.add(
+ TextSpan(
+ text: text.substring(lastIndex, match.start),
+ style: TextStyle(color: baseColor),
+ ),
+ );
+ }
+ spans.add(
+ TextSpan(
+ text: text.substring(match.start, match.end),
+ style: TextStyle(color: mentionColor, fontWeight: FontWeight.w700),
+ ),
+ );
+ lastIndex = match.end;
+ }
+ if (lastIndex < text.length) {
+ spans.add(
+ TextSpan(
+ text: text.substring(lastIndex),
+ style: TextStyle(color: baseColor),
+ ),
+ );
+ }
+ return spans;
+ }
+
+ String _escapeRegExp(String value) {
+ return value.replaceAllMapped(
+ RegExp(r'[\\^$.*+?()[\]{}|]'),
+ (match) => '\\${match[0]}',
+ );
+ }
+
+ DateTime? _earliestDate(DateTime? first, DateTime? second) {
+ if (first == null) return second;
+ if (second == null) return first;
+ return first.isBefore(second) ? first : second;
+ }
+
+ String _officeLabel(AsyncValue