Initial commit

This commit is contained in:
Marc Rejohn Castillano 2026-02-09 20:10:42 +08:00
parent 1a1cbb1bb3
commit 1f16da8f88
172 changed files with 12461 additions and 0 deletions

45
.metadata Normal file
View File

@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "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'

9
.vscode/mcp.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"servers": {
"supabase": {
"type": "http",
"url": "https://mcp.supabase.com/mcp?project_ref=wqjebgpbwrfzshaabprh"
}
},
"inputs": []
}

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# tasq
A new Flutter project.

1
analysis_options.yaml Normal file
View File

@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml

14
android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "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 = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="tasq"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

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

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

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

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

BIN
assets/tasq_ico.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

34
ios/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

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

View File

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

View File

@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = 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 */;
}

View File

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

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

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

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

49
ios/Runner/Info.plist Normal file
View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Tasq</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>tasq</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

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

View File

@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

22
lib/app.dart Normal file
View File

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

73
lib/main.dart Normal file
View File

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

View File

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

10
lib/models/office.dart Normal file
View File

@ -0,0 +1,10 @@
class Office {
Office({required this.id, required this.name});
final String id;
final String name;
factory Office.fromMap(Map<String, dynamic> map) {
return Office(id: map['id'] as String, name: map['name'] as String? ?? '');
}
}

15
lib/models/profile.dart Normal file
View File

@ -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<String, dynamic> map) {
return Profile(
id: map['id'] as String,
role: map['role'] as String? ?? 'standard',
fullName: map['full_name'] as String? ?? '',
);
}
}

50
lib/models/task.dart Normal file
View File

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

View File

@ -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<String, dynamic> map) {
return TaskAssignment(
taskId: map['task_id'] as String,
userId: map['user_id'] as String,
createdAt: DateTime.parse(map['created_at'] as String),
);
}
}

46
lib/models/ticket.dart Normal file
View File

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

View File

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

View File

@ -0,0 +1,13 @@
class UserOffice {
UserOffice({required this.userId, required this.officeId});
final String userId;
final String officeId;
factory UserOffice.fromMap(Map<String, dynamic> map) {
return UserOffice(
userId: map['user_id'] as String,
officeId: map['office_id'] as String,
);
}
}

View File

@ -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<AdminUserController>((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<void> 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<void> updateRole({
required String userId,
required String role,
}) async {
await _client.from('profiles').update({'role': role}).eq('id', userId);
}
Future<void> setPassword({
required String userId,
required String password,
}) async {
await _invokeAdminFunction(
action: 'set_password',
payload: {'userId': userId, 'password': password},
);
}
Future<void> setLock({required String userId, required bool locked}) async {
await _invokeAdminFunction(
action: 'set_lock',
payload: {'userId': userId, 'locked': locked},
);
}
Future<AdminUserStatus> fetchStatus(String userId) async {
final data = await _invokeAdminFunction(
action: 'get_user',
payload: {'userId': userId},
);
final user = (data as Map<String, dynamic>)['user'] as Map<String, dynamic>;
final bannedUntilRaw = user['banned_until'] as String?;
return AdminUserStatus(
email: user['email'] as String?,
bannedUntil: bannedUntilRaw == null
? null
: DateTime.tryParse(bannedUntilRaw),
);
}
Future<dynamic> _invokeAdminFunction({
required String action,
required Map<String, dynamic> 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<String, dynamic>) {
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.';
}
}

View File

@ -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<AuthState>((ref) {
final client = ref.watch(supabaseClientProvider);
return client.auth.onAuthStateChange;
});
final sessionProvider = Provider<Session?>((ref) {
final client = ref.watch(supabaseClientProvider);
return client.auth.currentSession;
});
final authControllerProvider = Provider<AuthController>((ref) {
final client = ref.watch(supabaseClientProvider);
return AuthController(client);
});
class AuthController {
AuthController(this._client);
final SupabaseClient _client;
Future<AuthResponse> signInWithPassword({
required String email,
required String password,
}) {
return _client.auth.signInWithPassword(email: email, password: password);
}
Future<AuthResponse> signUp({
required String email,
required String password,
String? fullName,
List<String>? 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<void> signInWithGoogle({String? redirectTo}) {
return _client.auth.signInWithOAuth(
OAuthProvider.google,
redirectTo: redirectTo,
);
}
Future<void> signInWithMeta({String? redirectTo}) {
return _client.auth.signInWithOAuth(
OAuthProvider.facebook,
redirectTo: redirectTo,
);
}
Future<void> signOut() {
return _client.auth.signOut();
}
}

View File

@ -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<List<NotificationItem>>((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<int>((ref) {
final notificationsAsync = ref.watch(notificationsProvider);
return notificationsAsync.maybeWhen(
data: (items) => items.where((item) => item.isUnread).length,
orElse: () => 0,
);
});
final notificationsControllerProvider = Provider<NotificationsController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
return NotificationsController(client);
});
class NotificationsController {
NotificationsController(this._client);
final SupabaseClient _client;
Future<void> createMentionNotifications({
required List<String> 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<void> markRead(String id) async {
await _client
.from('notifications')
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
.eq('id', id);
}
Future<void> 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<void> 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);
}
}

View File

@ -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<String?>((ref) {
final authState = ref.watch(authStateChangesProvider);
return authState.maybeWhen(
data: (state) => state.session?.user.id,
orElse: () => ref.watch(sessionProvider)?.user.id,
);
});
final currentProfileProvider = StreamProvider<Profile?>((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<List<Profile>>((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<bool>((ref) {
final profileAsync = ref.watch(currentProfileProvider);
return profileAsync.maybeWhen(
data: (profile) => profile?.role == 'admin',
orElse: () => false,
);
});

View File

@ -0,0 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
final supabaseClientProvider = Provider<SupabaseClient>((ref) {
return Supabase.instance.client;
});

View File

@ -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<List<Task>>((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 <Task>[]);
}
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() ??
<String>[];
final officeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet()
.toList() ??
<String>[];
if (allowedTicketIds.isEmpty && officeIds.isEmpty) {
return Stream.value(const <Task>[]);
}
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<List<TaskAssignment>>((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<TaskAssignmentsController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
return TaskAssignmentsController(client);
});
final tasksControllerProvider = Provider<TasksController>((ref) {
final client = ref.watch(supabaseClientProvider);
return TasksController(client);
});
class TasksController {
TasksController(this._client);
final SupabaseClient _client;
Future<void> updateTaskStatus({
required String taskId,
required String status,
}) async {
await _client.from('tasks').update({'status': status}).eq('id', taskId);
}
Future<void> 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<void> _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<List<String>> _fetchRoleUserIds({
required List<String> roles,
required String? excludeUserId,
}) async {
try {
final data = await _client
.from('profiles')
.select('id, role')
.inFilter('role', roles);
final rows = data as List<dynamic>;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.where((id) => id.isNotEmpty && id != excludeUserId)
.toList();
return ids;
} catch (_) {
return [];
}
}
}
class TaskAssignmentsController {
TaskAssignmentsController(this._client);
final SupabaseClient _client;
Future<void> replaceAssignments({
required String taskId,
required String? ticketId,
required List<String> newUserIds,
required List<String> 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<void> _notifyAssigned({
required String taskId,
required String? ticketId,
required List<String> 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<void> removeAssignment({
required String taskId,
required String userId,
}) async {
await _client
.from('task_assignments')
.delete()
.eq('task_id', taskId)
.eq('user_id', userId);
}
}

View File

@ -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<List<Office>>((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<List<Office>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('offices').select().order('name');
return (rows as List<dynamic>)
.map((row) => Office.fromMap(row as Map<String, dynamic>))
.toList();
});
final officesControllerProvider = Provider<OfficesController>((ref) {
final client = ref.watch(supabaseClientProvider);
return OfficesController(client);
});
final ticketsProvider = StreamProvider<List<Ticket>>((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 <Ticket>[]);
}
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() ??
<String>[];
if (officeIds.isEmpty) {
return Stream.value(const <Ticket>[]);
}
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<List<TicketMessage>, 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<List<TicketMessage>>((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<List<TicketMessage>, 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<TicketsController>((ref) {
final client = ref.watch(supabaseClientProvider);
return TicketsController(client);
});
class TicketsController {
TicketsController(this._client);
final SupabaseClient _client;
Future<void> 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<void> _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<List<String>> _fetchRoleUserIds({
required List<String> roles,
required String? excludeUserId,
}) async {
try {
final data = await _client
.from('profiles')
.select('id, role')
.inFilter('role', roles);
final rows = data as List<dynamic>;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.where((id) => id.isNotEmpty && id != excludeUserId)
.toList();
return ids;
} catch (_) {
return [];
}
}
Future<TicketMessage> 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<TicketMessage> sendTaskMessage({
required String taskId,
required String? ticketId,
required String content,
}) async {
final payload = <String, dynamic>{
'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<void> 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<void> createOffice({required String name}) async {
await _client.from('offices').insert({'name': name});
}
Future<void> updateOffice({required String id, required String name}) async {
await _client.from('offices').update({'name': name}).eq('id', id);
}
Future<void> deleteOffice({required String id}) async {
await _client.from('offices').delete().eq('id', id);
}
}

View File

@ -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<String> userIds;
final String channelStatus;
final Map<String, dynamic> lastPayload;
TypingIndicatorState copyWith({
Set<String>? userIds,
String? channelStatus,
Map<String, dynamic>? lastPayload,
}) {
return TypingIndicatorState(
userIds: userIds ?? this.userIds,
channelStatus: channelStatus ?? this.channelStatus,
lastPayload: lastPayload ?? this.lastPayload,
);
}
}
final typingIndicatorProvider = StateNotifierProvider.autoDispose
.family<TypingIndicatorController, TypingIndicatorState, String>((
ref,
ticketId,
) {
final client = ref.watch(supabaseClientProvider);
final controller = TypingIndicatorController(client, ticketId);
ref.onDispose(controller.dispose);
return controller;
});
class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
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<String, Timer> _remoteTimeouts = {};
void _initChannel() {
final channel = _client.channel('typing:$_ticketId');
channel.onBroadcast(
event: 'typing',
callback: (payload) {
final Map<String, dynamic> 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<String, dynamic> _extractPayload(dynamic payload) {
if (payload is Map<String, dynamic>) {
final inner = payload['payload'];
if (inner is Map<String, dynamic>) {
return inner;
}
return payload;
}
final dynamic inner = payload.payload;
if (inner is Map<String, dynamic>) {
return inner;
}
return <String, dynamic>{};
}
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();
}
}

View File

@ -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<List<UserOffice>>((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<UserOfficesController>((ref) {
final client = ref.watch(supabaseClientProvider);
return UserOfficesController(client);
});
class UserOfficesController {
UserOfficesController(this._client);
final SupabaseClient _client;
Future<void> assignUserOffice({
required String userId,
required String officeId,
}) async {
await _client.from('user_offices').insert({
'user_id': userId,
'office_id': officeId,
});
}
Future<void> removeUserOffice({
required String userId,
required String officeId,
}) async {
await _client
.from('user_offices')
.delete()
.eq('user_id', userId)
.eq('office_id', officeId);
}
}

160
lib/routing/app_router.dart Normal file
View File

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

View File

@ -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<void> _showOfficeDialog(
BuildContext context,
WidgetRef ref, {
Office? office,
}) async {
final nameController = TextEditingController(text: office?.name ?? '');
await showDialog<void>(
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<void> _confirmDelete(
BuildContext context,
WidgetRef ref,
Office office,
) async {
await showDialog<void>(
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'),
),
],
);
},
);
}
}

View File

@ -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<UserManagementScreen> createState() =>
_UserManagementScreenState();
}
class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
static const List<String> _roles = [
'standard',
'dispatcher',
'it_staff',
'admin',
];
final _fullNameController = TextEditingController();
String? _selectedUserId;
String? _selectedRole;
Set<String> _selectedOfficeIds = {};
AdminUserStatus? _selectedStatus;
bool _isSaving = false;
bool _isStatusLoading = false;
final Map<String, AdminUserStatus> _statusCache = {};
final Set<String> _statusLoading = {};
final Set<String> _statusErrors = {};
Set<String> _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<List<Profile>> profilesAsync,
AsyncValue<List<Office>> officesAsync,
AsyncValue<List<UserOffice>> assignmentsAsync,
AsyncValue<List<TicketMessage>> 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 = <String, DateTime>{};
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<Profile> profiles,
List<Office> offices,
List<UserOffice> assignments,
Map<String, DateTime> 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 = <String, int>{};
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<Profile> 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<void> _showUserDialog(
BuildContext context,
Profile profile,
List<Office> offices,
List<UserOffice> 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<void>(
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<Office> offices,
Set<String> 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<String>(
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<void> _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<bool> _saveChanges(
BuildContext context,
Profile profile,
Set<String> 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<void> _showPasswordResetDialog(String userId) async {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
await showDialog<void>(
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<void> _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';
}
}

View File

@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _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<void> _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'),
),
],
),
),
),
);
}
}

View File

@ -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<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends ConsumerState<SignUpScreen> {
final _formKey = GlobalKey<FormState>();
final _fullNameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final Set<String> _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<void> _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;
});
}
}

View File

@ -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<StaffRowMetrics> 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<AsyncValue<DashboardMetrics>>((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 <Ticket>[];
final tasks = tasksAsync.valueOrNull ?? const <Task>[];
final profiles = profilesAsync.valueOrNull ?? const <Profile>[];
final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[];
final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[];
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 = <String, DateTime>{};
final lastStaffMessageByUser = <String, DateTime>{};
final respondedTicketsByUser = <String, Set<String>>{};
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, () => <String>{})
.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<Duration>()
.toList();
final triageDurationsToday = ticketsToday
.map(triageDuration)
.whereType<Duration>()
.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 = <String>{};
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 = <String, Set<String>>{};
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, () => <String>{})
.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<Widget> 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<Duration> 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';
}

View File

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

View File

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

View File

@ -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<TaskDetailScreen> createState() => _TaskDetailScreenState();
}
class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
final _messageController = TextEditingController();
static const List<String> _statusOptions = [
'queued',
'in_progress',
'completed',
];
String? _mentionQuery;
int? _mentionStart;
List<Profile> _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 ?? <TaskAssignment>[];
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<List<Profile>> 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<TicketMessage> messages,
List<Profile> 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<int>(
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<Profile> 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<TextSpan> _mentionSpans(
String text,
Color baseColor,
Color mentionColor,
List<Profile> 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 = <TextSpan>[];
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<List<TicketMessage>> _mergeMessages(
AsyncValue<List<TicketMessage>> taskMessages,
AsyncValue<List<TicketMessage>>? ticketMessages,
) {
if (ticketMessages == null) {
return taskMessages;
}
return taskMessages.when(
data: (taskData) => ticketMessages.when(
data: (ticketData) {
final byId = <int, TicketMessage>{
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<List<TicketMessage>>(),
error: (error, stackTrace) =>
AsyncError<List<TicketMessage>>(error, stackTrace),
),
loading: () => const AsyncLoading<List<TicketMessage>>(),
error: (error, stackTrace) =>
AsyncError<List<TicketMessage>>(error, stackTrace),
);
}
Future<void> _handleSendMessage(
Task task,
List<Profile> 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<Profile> 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<String> _extractMentionedUserIds(
String content,
List<Profile> profiles,
String? currentUserId,
) {
final lower = content.toLowerCase();
final mentioned = <String>{};
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<List<Profile>> 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<String> userIds,
AsyncValue<List<Profile>> 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<List<Task>> tasksAsync, String taskId) {
return tasksAsync.maybeWhen(
data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull,
orElse: () => null,
);
}
Ticket? _findTicket(AsyncValue<List<Ticket>> 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<String>(
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<TaskAssignment> 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<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

View File

@ -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<void> _showCreateTaskDialog(
BuildContext context,
WidgetRef ref,
) async {
final titleController = TextEditingController();
final descriptionController = TextEditingController();
String? selectedOfficeId;
await showDialog<void>(
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<String>(
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<List<NotificationItem>> 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,
};
}
}

View File

@ -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<TicketDetailScreen> createState() => _TicketDetailScreenState();
}
class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
final _messageController = TextEditingController();
static const List<String> _statusOptions = ['pending', 'promoted', 'closed'];
String? _mentionQuery;
int? _mentionStart;
List<Profile> _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<List<Profile>> 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<TicketMessage> messages, List<Profile> 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<List<Task>> tasksAsync, String ticketId) {
return tasksAsync.maybeWhen(
data: (tasks) =>
tasks.where((task) => task.ticketId == ticketId).firstOrNull,
orElse: () => null,
);
}
Future<void> _handleSendMessage(
WidgetRef ref,
List<Profile> 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<String> _extractMentionedUserIds(
String content,
List<Profile> profiles,
String? currentUserId,
) {
final lower = content.toLowerCase();
final mentioned = <String>{};
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<Profile> 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<String> userIds,
AsyncValue<List<Profile>> 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<List<Profile>> 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<Profile> 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<TextSpan> _mentionSpans(
String text,
Color baseColor,
Color mentionColor,
List<Profile> 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 = <TextSpan>[];
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<List<Office>> officesAsync, Ticket ticket) {
final offices = officesAsync.valueOrNull ?? [];
final office = offices
.where((item) => item.id == ticket.officeId)
.firstOrNull;
return office?.name ?? ticket.officeId;
}
String _formatDate(DateTime value) {
final local = value.toLocal();
final monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final month = monthNames[local.month - 1];
final day = local.day.toString().padLeft(2, '0');
final year = local.year.toString();
final hour24 = local.hour;
final hour12 = hour24 % 12 == 0 ? 12 : hour24 % 12;
final minute = local.minute.toString().padLeft(2, '0');
final ampm = hour24 >= 12 ? 'PM' : 'AM';
return '$month $day, $year $hour12:$minute $ampm';
}
Future<void> _showTimelineDialog(BuildContext context, Ticket ticket) async {
await showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Ticket Timeline'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_timelineRow('Created', ticket.createdAt),
_timelineRow('Responded', ticket.respondedAt),
_timelineRow('Promoted', ticket.promotedAt),
_timelineRow('Closed', ticket.closedAt),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Close'),
),
],
);
},
);
}
Widget _timelineRow(String label, DateTime? value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('$label: ${value == null ? '' : _formatDate(value)}'),
);
}
String _formatDuration(Duration duration) {
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 _buildStatusChip(
BuildContext context,
WidgetRef ref,
Ticket ticket,
bool canPromote,
) {
final isLocked = ticket.status == 'promoted' || ticket.status == 'closed';
final chip = Chip(
label: Text(_statusLabel(ticket.status)),
backgroundColor: _statusColor(context, ticket.status),
labelStyle: TextStyle(
color: _statusTextColor(context, ticket.status),
fontWeight: FontWeight.w600,
),
);
if (isLocked) {
return chip;
}
final availableStatuses = canPromote
? _statusOptions
: _statusOptions.where((status) => status != 'promoted').toList();
return PopupMenuButton<String>(
onSelected: (value) async {
await ref
.read(ticketsControllerProvider)
.updateTicketStatus(ticketId: ticket.id, status: value);
ref.invalidate(ticketsProvider);
},
itemBuilder: (context) => availableStatuses
.map(
(status) => PopupMenuItem(
value: status,
child: Text(_statusMenuLabel(status)),
),
)
.toList(),
child: chip,
);
}
String _statusLabel(String status) {
return status.toUpperCase();
}
String _statusMenuLabel(String status) {
return switch (status) {
'pending' => 'Pending',
'promoted' => 'Promote to Task',
'closed' => 'Close',
_ => status,
};
}
bool _canPromote(String role) {
return role == 'admin' || role == 'dispatcher' || role == 'it_staff';
}
bool _canAssignStaff(String role) {
return role == 'admin' || role == 'dispatcher' || role == 'it_staff';
}
Color _statusColor(BuildContext context, String status) {
return switch (status) {
'pending' => Colors.amber.shade300,
'promoted' => Colors.blue.shade300,
'closed' => Colors.green.shade300,
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
};
}
Color _statusTextColor(BuildContext context, String status) {
return switch (status) {
'pending' => Colors.brown.shade900,
'promoted' => Colors.blue.shade900,
'closed' => Colors.green.shade900,
_ => Theme.of(context).colorScheme.onSurfaceVariant,
};
}
}
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

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