first commit

This commit is contained in:
Aiman Hafiz 2025-12-15 15:35:35 +08:00
parent 224f871141
commit c21384fb92
1760 changed files with 919434 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

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

154
WORK_LOG.md Normal file
View File

@ -0,0 +1,154 @@
# Work Log
## Friday, December 5, 2025
### Features & UI Updates
- **Scan Result User Screen (`scan_result_user.dart`)**
- **Refactoring**:
- Redesigned the UI to match the style of the Admin `ScanResultScreen`.
- Implemented a `DefaultTabController` with "Item Movement" and "Item Information" tabs.
- Adopted the "framed image" style and consistent Key-Value pair styling with icons.
- **Data & Display**:
- Added a **"Station"** field to both the "Item Movement" and "Item Information" tabs.
- Renamed "Current Information" container to **"Sender Information"** in the Item Movement tab.
- Cleaned up the "Sender Information" container to display **only the "User"**, removing the redundant "Store" and "Station" fields in that specific context.
- **Actions**:
- Integrated user-specific actions (Receive, Return, Deploy Station, Cancel) into the new `_buildDynamicActionSection` within the Item Movement tab.
- **Verification**:
- Successfully built the APK (`flutter build apk --debug`) to verify the changes.
## Thursday, December 4, 2025
### Features & Logic Updates
- **Dashboard Reporting (Admin & User)**
- **Service Layer**:
- Created `lib/services/report_service.dart` to interface with the `/InvMainAPI/GetInventoryReport/{deptId}` endpoint.
- **Admin Dashboard (`home_screen.dart`)**:
- Integrated `ReportService` to fetch real-time inventory statistics.
- Implemented logic to determine the correct department context:
- Fetches data for "All" (0) if the user is SuperAdmin/SystemAdmin.
- Fetches data for the specific department ID for other admin roles.
- Replaced the placeholder "Inventory Report" table with a dynamic table displaying:
- Total Items Registered
- Current Stock
- New Items (This/Last Month)
- Stock Out (This/Last Month)
- **User Dashboard (`home_screen_user.dart`)**:
- Integrated `ReportService` to fetch inventory statistics specific to the user's department.
- Updated the "INFORMATION" card (orange gradient) to display fetched data:
- Total Items
- Current Stock
- New (Month)
- Stock Out (Month)
- Added loading indicators (`...`) for data fields while fetching.
## Wednesday, December 3, 2025
### Features & Logic Updates
- **QR Scanning & Item Movement (User Side)**
- **New Screens**:
- Created `lib/screens/user/scan/scan_user.dart`: Dedicated camera scanning screen for users.
- Created `lib/screens/user/scan/scan_result_user.dart`: Implements user-specific scanning logic based on `QrUser.txt`.
- **Logic Implementation**:
- **Receive Item**: Only allowed if the item is "On Delivery" and assigned to the current user.
- **Return Item**: Allowed if the user currently holds the item.
- **Deploy Station**: Enables assigning an item to a station managed by the user.
- **Cancel Movement**: Implemented cancellation logic specific to user movements (similar to Admin but tailored for user context).
- **Status Handling**: Added logic to handle "Request Again" (already returned) and "Not Assigned to You" scenarios.
- **Type Safety**: Fixed type comparison bugs (User ID string vs int) to ensuring accurate permission checks.
- **Navigation**:
- Updated `BottomNavBar` to route non-admin users to `/scan-user` and admins to `/scan`.
- Registered `/scan-user` route in `main.dart`.
- Configured all actions (Receive, Return, Cancel, Deploy) to redirect back to the scanner screen upon success.
- **QR Scanning (Admin Side)**
- **Redirect Logic**: Updated `lib/screens/admin/scan/scan_result.dart` to redirect back to the scanner screen (`Navigator.pop`) instead of the Home Dashboard after successful actions (Receive, Add Movement, Cancel).
- **Modal Handling**: Fixed the "Add Movement" flow to correctly close both the modal and the result screen (double pop) on success.
- **Service Updates**
- **`ItemMovementService`**: Added endpoints for user actions: `updateItemMovementUser`, `returnItemMovementUser`, and `stationItemMovementUser`.
## Tuesday, December 2, 2025
### Features & Logic Updates
- **Item Movement User Pages (Refactoring to match Web App)**
- **Item View (`item_movement_item_user.dart`)**:
- Implemented filtering logic to truncate history at the point of a completed "Return" or "Ready To Deploy" status.
- Corrected data mapping for "Start" (formerly `last...`) and "End" (formerly `to...`) fields.
- Added **Status Headings** (Receive, Return, Change, Assign) with specific color coding.
- Added **Completion Status** (Complete, Incomplete, Canceled) indicators.
- Replaced the timeline visualization with a detailed list of movement cards for full history visibility.
- Updated Icon logic for Start/End locations.
- **Station View (`item_movement_station_user.dart`)**:
- Integrated `ItemMovementService` to fetch real data (removed mock data).
- Implemented grouping logic: Group by Station -> Group by Item -> History.
- Applied the same history filtering/truncation logic as the Item View.
- Updated UI components to be consistent with the detailed movement cards in Item View.
- **All View (`item_movement_all_user.dart`)**:
- Updated `_fetchInitialData` to group movements by `uniqueID` and select only the latest valid movement.
- Implemented filtering to **exclude** items from the main view if their latest status is "Return" or "Ready To Deploy" and they are completed.
- Restored missing state variables and fixed import issues during refactoring.
## Monday, December 1, 2025
### Features & Logic Updates
- **QR Scanning & Item Movement (`scan_result.dart`)**
- Completely rewrote `ScanResultScreen` to match the web application's logic (`QrMaster.txt`).
- Implemented dynamic UI tabs:
- **Item Information**: Displays read-only item details (Image, Name, Part/Serial No, Quantity, PIC).
- **Item Movement**: Displays current status and context-aware action buttons (Receive, Cancel, Add Movement).
- **Actions & Logic**:
- **Assigning**: Implemented a modal form to assign items to User, Station, Store, Supplier, or mark as Faulty.
- **Consignment Note**: Added functionality to upload Consignment Notes via Camera or File Gallery (converted to Base64).
- **Receiving**: Added logic to receive items based on their current status (e.g., "On Delivery" -> "Delivered", "Repair" -> "Ready To Deploy").
- **Cancellation**: Implemented a complex cancellation flow that updates the movement status to "Cancelled", creates a new "Register" movement to restore stock, and updates the item quantity.
- **Redirect**: Configured automatic redirection to the Dashboard (`/home`) upon successful completion of any action.
- **Service Updates**
- **`ItemService`**: Added `getItem` (fetch by ID) and `updateItemQuantity`.
- **`ItemMovementService`**: Added `addItemMovement`, `updateItemMovementMaster`, and `getItemMovementById`.
## Wednesday, November 26, 2025
### Features & Logic Updates
- **Admin Filtering (Item Movement & Product Request)**
- Implemented client-side department-based filtering for "Inventory Master" role in `item_movement_service.dart` and `product_request_service.dart`.
- Logic now ensures Inventory Masters only see requests/movements involving users from their own department (combined with existing store management logic).
- Fetched user lists to map User IDs to Department IDs dynamically.
- **UI Updates**
- **Navigation Bar (`nav_bar.dart`)**: Updated the Drawer Header to display the logged-in user's **Department Name** below their role.
- **Dashboard Experiments**: Briefly implemented and then reverted Department Name displays on Admin and User Dashboards and Title Bars based on user preference.
## Tuesday, November 25, 2025
### Features & UI Updates
- **Product Request User Page (`product_request_user.dart`)**
- Removed section headers ("Pending Request", "Complete Request", "Rejected Request").
- Updated "Requested" and "Rejected" details layout to a 3-column grid.
- Increased animation speed for expanding/collapsing details.
- Added "Pull to Refresh" functionality.
- Added a "Show" button for documents/pictures to view them in a dialog.
- Implemented "Delete Request" functionality connected to the API.
- **Item Movement User Pages**
- Added "Pull to Refresh" to:
- `item_movement_all_user.dart`
- `item_movement_item_user.dart`
- `item_movement_station_user.dart`
- **Item Movement Item User (`item_movement_item_user.dart`)**
- Removed product image from the main card.
- Simplified title to show only Item ID (matching Admin style).
- Confirmed usage of `timeline_tile`.
- **Product Request Form (`product_request_user_form.dart`)**
- Integrated `file_picker` package.
- Implemented file selection for documents/pictures.
- Added Base64 conversion for file upload payload.
- **Admin & Technician Pages**
- **Inventory Master (`invMaster_to_invMaster.dart`)**: Added "Show" button for documents/pictures.
- **Technician (`technician_to_invMaster.dart`)**: Added "Show" button for documents/pictures.
### Fixes
- Fixed a compilation error in `product_request_service.dart` (missing closing brace).

28
analysis_options.yaml Normal file
View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

13
android/.gitignore vendored Normal file
View File

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

44
android/app/build.gradle Normal file
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.inventory_system"
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.inventory_system"
// 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 = 36
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.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="inventory_system"
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.inventory_system
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

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

View File

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

31
android/build.gradle Normal file
View File

@ -0,0 +1,31 @@
buildscript {
ext.kotlin_version = '2.1.0'
repositories {
google()
mavenCentral()
}
dependencies {
// THIS IS THE FIX
classpath 'com.android.tools.build:gradle:8.6.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true
android.nonTransitiveRClass=true
# This is the most critical line. It forces Gradle to use the Java 17
# that comes bundled with Android Studio.
org.gradle.java.home=C:\\Program Files\\Java\\jdk-17

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.8-all.zip

29
android/settings.gradle Normal file
View File

@ -0,0 +1,29 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
// 1. CHANGE THIS VERSION from 8.4.0 (or 8.1.0) to 8.6.0
id "com.android.application" version "8.6.0" apply false
// 2. CHANGE THIS VERSION from 1.8.20 (or 1.9.23) to 2.1.0
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"

BIN
assets/images/battery.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
assets/images/ram.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
assets/images/sontek.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

BIN
assets/images/xdream.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

1289
database/pstw_cs (1).sql Normal file

File diff suppressed because it is too large Load Diff

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

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>12.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 = 12.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.inventorySystem;
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.inventorySystem.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.inventorySystem.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.inventorySystem.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.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.inventorySystem;
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.inventorySystem;
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,98 @@
<?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"
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"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

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

View File

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

View File

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

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>Inventory System</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>inventory_system</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.
}
}

168
lib/main.dart Normal file
View File

@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'screens/splash_screen.dart';
import 'screens/login_screen.dart';
import 'screens/user/home_screen/home_screen_user.dart';
import 'screens/user/product_request/product_request_user.dart';
import 'screens/user/item_movement/item_movement_all_user.dart';
import 'screens/user/item_movement/item_movement_item_user.dart';
import 'screens/user/item_movement/item_movement_station_user.dart';
import 'screens/user/scan/scan_user.dart';
import 'screens/admin/home_screen/home_screen.dart';
import 'screens/admin/supplier/supplier.dart';
import 'screens/admin/manufacturer/manufacturer.dart';
import 'screens/admin/station/station.dart';
import 'screens/admin/product/product.dart';
import 'screens/admin/item/item.dart';
import 'screens/admin/scan/scan.dart';
import 'screens/admin/item_movement/item_movement_all.dart';
import 'screens/admin/item_movement/item_movement_item.dart';
import 'screens/admin/item_movement/item_movement_station.dart';
import 'screens/admin/product_request/technician_to_invMaster.dart';
import 'screens/admin/product_request/invMaster_to_invMaster.dart';
import 'screens/admin/product_request/product_request_form.dart';
import 'routes/slide_route.dart';
import 'package:inventory_system/services/api_service.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await ApiService.init();
runApp(const InventorySystemApp());
}
class InventorySystemApp extends StatelessWidget {
const InventorySystemApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Inventory System',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.red,
primaryColor: const Color(0xFF8B0000),
scaffoldBackgroundColor: Colors.white,
fontFamily: 'Roboto',
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF8B0000), width: 2),
),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF8B0000),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
),
),
initialRoute: '/',
// Use a single onGenerateRoute so we can control transition per route
onGenerateRoute: (RouteSettings settings) {
final name = settings.name;
Widget? page;
// Map route names to pages
switch (name) {
case '/':
page = const SplashScreen();
break;
case '/login':
page = const LoginScreen();
break;
case '/home':
page = const HomeScreen();
break;
case '/user-home':
page = const UserHomeScreen();
break;
case '/supplier':
page = const SupplierScreen();
break;
case '/manufacturer':
page = const ManufacturerScreen();
break;
case '/station':
page = const StationScreen();
break;
case '/product':
page = const ProductScreen();
break;
case '/item':
page = const ItemScreen();
break;
case '/scan':
page = const ScanScreen();
break;
case '/scan-user':
page = const ScanUserScreen();
break;
case '/item_movement_all':
page = const ItemMovementAllScreen();
break;
case '/item_movement_item':
page = ItemMovementItemScreen();
break;
case '/item_movement_station':
page = ItemMovementStationScreen();
break;
case '/product_request_t_to_im':
page = const TechnicianToInvMasterScreen();
break;
case '/product_request_im_to_im':
page = const InvMasterToInvMasterScreen();
break;
case '/product_request_form':
page = const ProductRequestFormScreen();
break;
case '/product_request_user':
page = const ProductRequestUserScreen();
break;
case '/item_movement_all_user':
page = const ItemMovementAllUserScreen();
break;
case '/item_movement_item_user':
page = const ItemMovementItemUserScreen();
break;
case '/item_movement_station_user':
page = const ItemMovementStationUserScreen();
break;
}
if (page == null) {
// Unknown route: fallback to default behavior
return MaterialPageRoute(builder: (_) => const SplashScreen());
}
// Exclusion list: use default MaterialPageRoute (no slide)
final excluded = <String>{
'/login',
'/scan',
'/product_request_form',
// Any other add/edit forms can be added here if you wire them as named routes later
};
if (excluded.contains(name)) {
return MaterialPageRoute(builder: (_) => page!, settings: settings);
}
// Default: slide transition
return createSlideRoute(page);
},
);
}
}

View File

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
/// Creates a right-to-left slide route. Use for most navigations
Route<T> createSlideRoute<T>(Widget page, {Duration duration = const Duration(milliseconds: 300)}) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: duration,
reverseTransitionDuration: duration,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(1.0, 0.0); // slide from right
const end = Offset.zero;
const curve = Curves.ease;
final tween = Tween<Offset>(begin: begin, end: end).chain(CurveTween(curve: curve));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}

View File

@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/utils/exit_dialog.dart';
import 'package:inventory_system/services/user_service.dart';
import 'package:inventory_system/services/report_service.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 0;
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
Map<String, dynamic>? _reportData;
bool _isLoadingReport = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadReportData();
}
Future<void> _loadReportData() async {
setState(() {
_isLoadingReport = true;
_errorMessage = null;
});
try {
final userService = UserService();
final userInfoData = await userService.getUserInfo();
if (userInfoData.isEmpty || !userInfoData.containsKey('userInfo')) {
throw Exception("Failed to retrieve user information");
}
final userInfo = userInfoData['userInfo'];
final userRole = userInfo['role'];
final department = userInfo['department'];
int deptId = 0; // Default to 0 (All)
if (userRole == "SuperAdmin" || userRole == "SystemAdmin") {
deptId = 0;
} else if (department != null && department['departmentId'] != null) {
deptId = department['departmentId'];
}
final reportService = ReportService();
final data = await reportService.fetchInventoryReport(deptId);
if (mounted) {
setState(() {
_reportData = data;
_isLoadingReport = false;
});
}
} catch (e) {
debugPrint("Error loading report data: $e");
if (mounted) {
setState(() {
_isLoadingReport = false;
_errorMessage = "Failed to load report";
});
}
}
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
if (didPop) return;
if (_scaffoldKey.currentState?.isDrawerOpen ?? false) {
Navigator.of(context).pop();
return;
}
final shouldExit = await showExitConfirmationDialog(context);
if (shouldExit) {
SystemNavigator.pop();
}
},
child: Scaffold(
key: _scaffoldKey,
backgroundColor: const Color(0xFFF5F5F7),
appBar: const TitleBar(title: 'Admin Dashboard'),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.dashboard),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Dashboard Cards - 2 columns grid
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
_buildDashboardCard(
title: 'Supplier',
subtitle: 'Manage suppliers',
icon: Icons.local_shipping_rounded,
color: Colors.blue.shade700,
onTap: () {
Navigator.pushNamed(context, '/supplier');
},
),
_buildDashboardCard(
title: 'Manufacturer',
subtitle: 'View manufacturers',
icon: Icons.factory_rounded,
color: Colors.blue.shade700,
onTap: () {
Navigator.pushNamed(context, '/manufacturer');
},
),
_buildDashboardCard(
title: 'Station',
subtitle: 'Location management',
icon: Icons.location_on_rounded,
color: Colors.blue.shade700,
onTap: () {
Navigator.pushNamed(context, '/station');
},
),
_buildDashboardCard(
title: 'Product',
subtitle: 'Product catalog',
icon: Icons.inventory_2_rounded,
color: Colors.blue.shade700,
onTap: () {
Navigator.pushNamed(context, '/product');
},
),
_buildDashboardCard(
title: 'Item',
subtitle: 'All inventory items',
icon: Icons.category_rounded,
color: Colors.blue.shade700,
onTap: () {
Navigator.pushNamed(context, '/item');
},
),
_buildDashboardCard(
title: 'Item Movement',
subtitle: 'Track transfers',
icon: Icons.swap_horiz_rounded,
color: Colors.blue.shade700,
onTap: () {
Navigator.pushNamed(context, '/item_movement_all');
},
),
],
),
const SizedBox(height: 12),
// Product Request Card - Centered
Center(
child: _buildDashboardCard(
title: 'Product Request',
subtitle: 'Manage requests',
icon: Icons.assignment_rounded,
color: Colors.blue.shade700,
onTap: () {
Navigator.pushNamed(context, '/product_request_t_to_im');
},
width: MediaQuery.of(context).size.width * 0.47,
),
),
const SizedBox(height: 12),
// Inventory Report
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Inventory Report',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
if (_isLoadingReport) ...[
const SizedBox(width: 10),
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2)
),
]
],
),
const SizedBox(height: 12),
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
)
else
Table(
border: TableBorder.all(color: Colors.grey.shade300, width: 1),
columnWidths: const {
0: FlexColumnWidth(1.5),
1: FlexColumnWidth(1.5),
2: FlexColumnWidth(1.5),
},
children: [
TableRow(
decoration: BoxDecoration(color: Colors.grey.shade50),
children: [
_buildTableHeader('Statistic'),
_buildTableHeader('Item Registered'),
_buildTableHeader('Item Stock Out'),
],
),
TableRow(
children: [
_buildTableCell('Item Registered: ${_reportData?['itemCountRegistered'] ?? 0}', isLeft: true),
_buildTableCell('This Month: ${_reportData?['itemCountRegisteredThisMonth'] ?? 0}'),
_buildTableCell('This Month: ${_reportData?['itemCountStockOutThisMonth'] ?? 0}'),
],
),
TableRow(
children: [
_buildTableCell('Item In Stock: ${_reportData?['itemCountStillInStock'] ?? 0}', isLeft: true),
_buildTableCell('Last Month: ${_reportData?['itemCountRegisteredLastMonth'] ?? 0}'),
_buildTableCell('Last Month: ${_reportData?['itemCountStockOutLastMonth'] ?? 0}'),
],
),
],
),
],
),
),
],
),
),
),
],
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
setState(() {
_selectedIndex = index;
});
},
),
),
);
}
Widget _buildDashboardCard({
required String title,
required String subtitle,
required IconData icon,
required Color color,
required VoidCallback onTap,
double? width,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
splashColor: Colors.white.withOpacity(0.2),
highlightColor: Colors.white.withOpacity(0.1),
child: Ink(
width: width,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: Colors.white, size: 36),
const SizedBox(height: 6),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
color: Colors.white70,
fontSize: 11,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
Widget _buildTableHeader(String text) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
text,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
);
}
Widget _buildTableCell(String text, {bool isLeft = false}) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
text,
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade700,
),
textAlign: isLeft ? TextAlign.left : TextAlign.center,
),
);
}
}

View File

@ -0,0 +1,439 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:inventory_system/services/item_service.dart';
import 'package:inventory_system/screens/admin/item/item_form.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:qr_flutter/qr_flutter.dart';
class ItemScreen extends StatefulWidget {
const ItemScreen({super.key});
@override
State<ItemScreen> createState() => _ItemScreenState();
}
class _ItemScreenState extends State<ItemScreen> {
int _selectedIndex = 0;
final ItemService _itemService = ItemService();
late Future<List<dynamic>> _itemsFuture;
String _selectedFilter = 'All';
final List<String> _filters = ['All', 'Asset', 'Disposable', 'Part'];
List<dynamic> _allItems = [];
List<dynamic> _filteredItems = [];
final TextEditingController _searchController = TextEditingController();
int? _expandedItemId;
@override
void initState() {
super.initState();
_itemsFuture = _fetchItems();
_searchController.addListener(_filterAndSortItems);
}
Future<List<dynamic>> _fetchItems() async {
try {
final items = await _itemService.fetchItems();
if (mounted) {
setState(() {
_allItems = items;
_filterAndSortItems();
});
}
return items;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load items: $e'), backgroundColor: Colors.red),
);
}
rethrow;
}
}
@override
void dispose() {
_searchController.removeListener(_filterAndSortItems);
_searchController.dispose();
super.dispose();
}
void _filterAndSortItems() {
final query = _searchController.text.toLowerCase();
final filtered = _allItems.where((item) {
final name = item['productName']?.toString().toLowerCase() ?? '';
final serial = item['serialNumber']?.toString().toLowerCase() ?? '';
final category = item['category']?.toString().toLowerCase() ?? '';
final uniqueId = item['uniqueID']?.toString().toLowerCase() ?? '';
final matchesSearch = name.contains(query) || serial.contains(query) || uniqueId.contains(query);
final matchesFilter = _selectedFilter == 'All' || category == _selectedFilter.toLowerCase();
return matchesSearch && matchesFilter;
}).toList();
// Sort the filtered list by uniqueID
filtered.sort((a, b) {
final idA = a['uniqueID'] as String? ?? '';
final idB = b['uniqueID'] as String? ?? '';
return idA.compareTo(idB);
});
setState(() {
_filteredItems = filtered;
_expandedItemId = null;
});
}
void _deleteItem(int itemId) async {
try {
await _itemService.deleteItem(itemId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Item deleted successfully'), backgroundColor: Colors.green),
);
_fetchItems();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete item: $e'), backgroundColor: Colors.red),
);
}
}
}
void _confirmDelete(int itemId, String itemName) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Item'),
content: Text('Are you sure you want to delete $itemName?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(
onPressed: () {
Navigator.pop(context);
_deleteItem(itemId);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _showPrintDialog(Map<String, dynamic> item) {
showDialog(
context: context,
builder: (context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Column(
children: [
QrImageView(
data: item['uniqueID'] ?? '',
version: QrVersions.auto,
size: 100.0,
),
const SizedBox(height: 8),
Text(
item['uniqueID'] ?? '',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
],
),
const SizedBox(width: 24),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item['currentStore'] ?? 'N/A', style: const TextStyle(fontFamily: 'monospace', fontSize: 16)),
const SizedBox(height: 8),
Text(item['productShortName'] ?? item['productName'] ?? '', style: const TextStyle(fontFamily: 'monospace', fontSize: 16)),
const SizedBox(height: 8),
Text(item['serialNumber'] ?? '', style: const TextStyle(fontFamily: 'monospace', fontSize: 16)),
const SizedBox(height: 8),
Text(item['partNumber'] ?? '', style: const TextStyle(fontFamily: 'monospace', fontSize: 16)),
],
),
),
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey.shade200,
foregroundColor: Colors.black87,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8), side: BorderSide(color: Colors.grey.shade400)),
),
child: const Text('Print QR Info'),
),
),
],
),
),
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: const TitleBar(title: 'Item'),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.item),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ItemFormScreen()),
).then((success) {
if (success == true) _fetchItems();
});
},
icon: const Icon(Icons.add, size: 18),
label: const Text('Add Item'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade100,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final filter = _filters[index];
final isSelected = _selectedFilter == filter;
return ChoiceChip(
label: Text(filter),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() {
_selectedFilter = filter;
_filterAndSortItems();
});
}
},
backgroundColor: Colors.white,
selectedColor: Colors.purple.shade100,
labelStyle: TextStyle(color: isSelected ? Colors.purple.shade900 : Colors.black54, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18), side: BorderSide(color: isSelected ? Colors.purple.shade100 : Colors.grey.shade300)),
);
},
),
),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchItems,
child: FutureBuilder<List<dynamic>>(
future: _itemsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting && _allItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && _allItems.isEmpty) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (_filteredItems.isEmpty) {
return const Center(child: Text('No items found.'));
}
return ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
final item = _filteredItems[index];
return _buildItemWidget(item);
},
);
},
),
),
),
],
),
),
bottomNavigationBar: BottomNavBar(selectedIndex: _selectedIndex, onItemTapped: (index) {
if (index == 0) { Navigator.pop(context); } else if (index == 1) { if (mounted) { Navigator.pushNamed(context, '/scan'); } } else { setState(() { _selectedIndex = index; }); }
}),
);
}
Widget _buildItemWidget(Map<String, dynamic> item) {
final isExpanded = _expandedItemId == item['itemID'];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Slidable(
key: Key(item['itemID'].toString()),
endActionPane: ActionPane(
motion: const ScrollMotion(),
extentRatio: 0.45,
children: [
SlidableAction(onPressed: (context) => Navigator.push(context, MaterialPageRoute(builder: (context) => ItemFormScreen(item: item))).then((success) => {if(success == true) _fetchItems()}), backgroundColor: Colors.blue, foregroundColor: Colors.white, icon: Icons.edit, label: 'Edit'),
SlidableAction(onPressed: (context) => _confirmDelete(item['itemID'], item['productName']), backgroundColor: Colors.red, foregroundColor: Colors.white, icon: Icons.delete, label: 'Delete'),
],
),
child: Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.grey.shade300, width: 1)),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 80, height: 80, padding: const EdgeInsets.all(4), decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8)),
child: QrImageView(data: item['uniqueID'] ?? '', version: QrVersions.auto, size: 72),
),
const SizedBox(height: 4),
Text(item['uniqueID'] ?? '', style: TextStyle(fontSize: 12, color: Colors.black)),
],
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item['productName'] ?? 'N/A', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Text('Serial Number: ${item['serialNumber'] ?? 'N/A'}', style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
const SizedBox(height: 4),
Text('Part Number: ${item['partNumber'] ?? 'N/A'}', style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
],
),
),
ElevatedButton.icon(onPressed: () => _showPrintDialog(item), icon: const Icon(Icons.print, size: 16), label: const Text('Print'), style: ElevatedButton.styleFrom(backgroundColor: Colors.green.shade400, foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
],
),
),
InkWell(
onTap: () => setState(() => _expandedItemId = isExpanded ? null : item['itemID']),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration(border: Border(top: BorderSide(color: Colors.grey.shade200))),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(isExpanded ? 'Less Details' : 'More Details', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.blue.shade600)),
const SizedBox(width: 4),
Icon(isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, color: Colors.blue.shade600, size: 20),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isExpanded
? Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.grey.shade50, border: Border(top: BorderSide(color: Colors.grey.shade200))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildDetailItem('Category', item['category'] ?? 'N/A')),
const SizedBox(width: 16),
Expanded(child: _buildDetailItem('Quantity', item['quantity'].toString())),
const SizedBox(width: 16),
Expanded(child: _buildDetailItem('Price', 'RM${item['convertPrice']?.toString() ?? '0.00'}'))]),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildDetailItem('Supplier', item['supplier'] ?? 'N/A')),
const SizedBox(width: 16),
Expanded(child: _buildDetailItem('Purchase Date', item['purchaseDate'] ?? 'N/A')),
const SizedBox(width: 16),
Expanded(child: _buildDetailItem('Warranty Until', item['endWDate'] ?? 'N/A'))]),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildDetailItem('Location', item['currentStation'] ?? 'N/A')),
const SizedBox(width: 16),
Expanded(child: _buildDetailItem('Store', item['currentStore'] ?? 'N/A')),
const SizedBox(width: 16),
Expanded(child: _buildDetailItem('User', item['currentUser'] ?? 'N/A')),
],
),
],
),
)
: const SizedBox.shrink(),
),
],
),
),
),
);
}
Widget _buildDetailRow(String label, String value) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [SizedBox(width: 60, child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Colors.grey.shade700))), Expanded(child: Text(value, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black87)))]);
Widget _buildDetailItem(String label, String value) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(label, style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500)), if (value.isNotEmpty) const SizedBox(height: 4), if (value.isNotEmpty) Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black87))]);
}
}

View File

@ -0,0 +1,585 @@
import 'dart:async';
import 'dart:convert'; // Added this import
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:inventory_system/services/department_service.dart';
import 'package:inventory_system/services/item_service.dart';
import 'package:inventory_system/services/product_service.dart';
import 'package:inventory_system/services/supplier_service.dart';
import 'package:inventory_system/services/currency_service.dart';
import 'package:inventory_system/services/session_manager.dart';
import 'package:inventory_system/services/api_service.dart';
import 'package:intl/intl.dart';
class ItemFormScreen extends StatefulWidget {
final Map<String, dynamic>? item;
const ItemFormScreen({super.key, this.item});
@override
State<ItemFormScreen> createState() => _ItemFormScreenState();
}
class _ItemFormScreenState extends State<ItemFormScreen> {
final _formKey = GlobalKey<FormState>();
final _itemService = ItemService();
final _productService = ProductService();
final _supplierService = SupplierService();
final _currencyService = CurrencyService();
final _departmentService = DepartmentService();
bool _isLoading = false;
// Controllers
final _companyController = TextEditingController();
final _departmentController = TextEditingController();
final _productCategoryController = TextEditingController();
final _partNumberController = TextEditingController();
final _quantityController = TextEditingController(text: '1');
final _poNoController = TextEditingController();
final _defaultPriceController = TextEditingController();
final _currencyRateController = TextEditingController(text: '1');
final _itemPriceController = TextEditingController(text: '0.00');
final _doNumberController = TextEditingController();
final _warrantyController = TextEditingController();
final _invoiceNumberController = TextEditingController();
final _serialNumberController = TextEditingController();
// Dates
DateTime? _purchaseDate;
DateTime? _doDate;
DateTime? _warrantyEndDate;
DateTime? _invoiceDate;
// Dropdowns
final List<String> _teams = ['Continuous', 'Manual'];
String? _selectedTeam;
List<dynamic> _products = [];
dynamic _selectedProduct;
List<dynamic> _suppliers = [];
dynamic _selectedSupplier;
Map<String, dynamic> _currencies = {};
String? _selectedCurrency;
List<dynamic> _companies = [];
dynamic _selectedCompany;
List<dynamic> _departmentsForSelectedCompany = [];
dynamic _selectedDepartment;
@override
void initState() {
super.initState();
_setupInitialData();
_defaultPriceController.addListener(_calculateItemPrice);
_currencyRateController.addListener(_calculateItemPrice);
_warrantyController.addListener(_calculateWarrantyEnd);
if (widget.item != null) {
_populateForm(widget.item!);
}
}
void _setupInitialData() {
final currentUser = SessionManager.instance.currentUser;
final isSuperAdmin = currentUser?['isSuperAdmin'] ?? false;
if (!isSuperAdmin && currentUser != null) {
_companyController.text = currentUser['company'] ?? '';
_departmentController.text = currentUser['department']?['departmentName'] ?? '';
}
_fetchDropdownData();
}
void _populateForm(Map<String, dynamic> item) {
_selectedTeam = item['teamType'];
_partNumberController.text = item['partNumber']?.toString() ?? '';
_quantityController.text = item['quantity']?.toString() ?? '1';
_poNoController.text = item['poNo']?.toString() ?? '';
_defaultPriceController.text = item['defaultPrice']?.toString() ?? '0.00';
_currencyRateController.text = item['currencyRate']?.toString() ?? '1';
_itemPriceController.text = item['convertPrice']?.toString() ?? '0.00';
_doNumberController.text = item['doNo']?.toString() ?? '';
_warrantyController.text = item['warranty']?.toString() ?? '';
_invoiceNumberController.text = item['invoiceNo']?.toString() ?? '';
_serialNumberController.text = item['serialNumber']?.toString() ?? '';
if (item['purchaseDate'] != null) _purchaseDate = DateTime.tryParse(item['purchaseDate']);
if (item['doDate'] != null) {
_doDate = DateTime.tryParse(item['doDate']);
}
if (item['endWDate'] != null) _warrantyEndDate = DateTime.tryParse(item['endWDate']);
if (item['invoiceDate'] != null) _invoiceDate = DateTime.tryParse(item['invoiceDate']);
_calculateItemPrice();
_calculateWarrantyEnd();
}
Future<void> _fetchDropdownData() async {
setState(() => _isLoading = true);
try {
final products = await _productService.fetchProducts();
final suppliers = await _supplierService.fetchSuppliers();
final currencies = await _currencyService.fetchCurrencies();
final companies = await _departmentService.fetchDepartments();
if (!mounted) return;
setState(() {
_products = products;
_suppliers = suppliers;
_currencies = currencies;
_companies = companies;
_selectedCurrency = _currencies.keys.contains('MYR') ? 'MYR' : null;
if (widget.item != null) {
final item = widget.item!;
_selectedProduct = _products.firstWhere((p) => p['productId'] == item['productId'], orElse: () => null);
if (_selectedProduct != null) {
_productCategoryController.text = _selectedProduct['category'] ?? '';
}
_selectedSupplier = _suppliers.firstWhere((s) => s['supplierCompName'] == item['supplier'], orElse: () => null);
if (_currencies.keys.contains(item['currency'])) {
_selectedCurrency = item['currency'];
}
_selectedCompany = _companies.firstWhere((c) => c['companyId'] == item['companyId'], orElse: () => null);
if (_selectedCompany != null) {
_departmentsForSelectedCompany = _selectedCompany['departments'] ?? [];
_selectedDepartment = _departmentsForSelectedCompany.firstWhere((d) => d['departmentId'] == item['departmentId'], orElse: () => null);
}
}
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load form data: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
void dispose() {
_companyController.dispose();
_departmentController.dispose();
_productCategoryController.dispose();
_partNumberController.dispose();
_quantityController.dispose();
_poNoController.dispose();
_defaultPriceController.dispose();
_currencyRateController.dispose();
_itemPriceController.dispose();
_doNumberController.dispose();
_warrantyController.dispose();
_invoiceNumberController.dispose();
_serialNumberController.dispose();
super.dispose();
}
void _calculateItemPrice() {
final defaultPrice = double.tryParse(_defaultPriceController.text) ?? 0.0;
final currencyRate = double.tryParse(_currencyRateController.text) ?? 1.0;
final calculatedPrice = defaultPrice * currencyRate;
if (mounted) {
setState(() {
_itemPriceController.text = calculatedPrice.toStringAsFixed(2);
});
}
}
void _calculateWarrantyEnd() {
if (_doDate != null) {
final warrantyMonths = int.tryParse(_warrantyController.text) ?? 0;
if (mounted) {
setState(() {
_warrantyEndDate = DateTime(_doDate!.year, _doDate!.month + warrantyMonths, _doDate!.day);
});
}
}
}
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please correct the errors in the form'), backgroundColor: Colors.red),
);
return;
}
setState(() => _isLoading = true);
final currentUser = SessionManager.instance.currentUser;
final isSuperAdmin = currentUser?['isSuperAdmin'] ?? false;
final String category = _selectedProduct?['category'] ?? '';
final int companyId = isSuperAdmin
? (_selectedCompany?['companyId'] ?? 0)
: (currentUser?['department']?['companyId'] ?? 0);
final int departmentId = isSuperAdmin
? (_selectedDepartment?['departmentId'] ?? 0)
: (currentUser?['department']?['departmentId'] ?? 0);
final itemData = {
'ItemID': widget.item?['itemID'] ?? 0,
'CompanyId': companyId,
'DepartmentId': departmentId,
'ProductId': _selectedProduct?['productId'] ?? 0,
'TeamType': _selectedTeam,
'PartNumber': _partNumberController.text,
'Quantity': (category == 'Asset' || category == 'Part') ? 1 : int.tryParse(_quantityController.text) ?? 1,
'Supplier': _selectedSupplier?['supplierCompName'] ?? '',
'PurchaseDate': _purchaseDate != null ? DateFormat('yyyy-MM-dd').format(_purchaseDate!) : null,
'PONo': _poNoController.text,
'Currency': _selectedCurrency,
'DefaultPrice': double.tryParse(_defaultPriceController.text) ?? 0,
'CurrencyRate': double.tryParse(_currencyRateController.text) ?? 1,
'ConvertPrice': _itemPriceController.text,
'DONo': _doNumberController.text,
'DODate': _doDate != null ? DateFormat('yyyy-MM-dd').format(_doDate!) : null,
'Warranty': int.tryParse(_warrantyController.text) ?? 0,
'EndWDate': _warrantyEndDate != null ? DateFormat('yyyy-MM-dd').format(_warrantyEndDate!) : null,
'InvoiceNo': _invoiceNumberController.text,
'InvoiceDate': _invoiceDate != null ? DateFormat('yyyy-MM-dd').format(_invoiceDate!) : null,
'CreatedByUserId': currentUser?['id'] ?? 1,
'SerialNumber': (category == 'Disposable') ? '' : _serialNumberController.text,
'createdDate': null,
'modifiedDate': null,
};
// Debugging: Print the data being sent
final jsonItemData = jsonEncode(itemData);
debugPrint('Submitting item data: $jsonItemData');
try {
if (widget.item == null) {
await _itemService.addItem(itemData);
} else {
await _itemService.updateItem(itemData);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Item saved successfully'), backgroundColor: Colors.green));
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
// Enhanced error logging
debugPrint('Failed to save item. Error: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save item: $e'), backgroundColor: Colors.red),
);
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.item != null;
final selectedCategory = _selectedProduct?['category'] as String?;
final isSuperAdmin = SessionManager.instance.currentUser?['isSuperAdmin'] ?? false;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black87),
onPressed: () => Navigator.pop(context),
),
title: Text(isEdit ? 'Edit Item' : 'Registration Item', style: const TextStyle(color: Colors.black87, fontSize: 18, fontWeight: FontWeight.w600),)
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isSuperAdmin)
...[
_buildLabel('Company'),
const SizedBox(height: 8),
_buildDropdown(_companies, _selectedCompany, 'Select Company',
(val) => setState(() {
_selectedCompany = val;
_departmentsForSelectedCompany = _selectedCompany != null ? _selectedCompany['departments'] : [];
_selectedDepartment = null;
}),
(item) => item['companyName'] ?? '', isRequired: true),
const SizedBox(height: 16),
_buildLabel('Department'),
const SizedBox(height: 8),
_buildDropdown(_departmentsForSelectedCompany, _selectedDepartment, 'Select Department',
(val) => setState(() => _selectedDepartment = val),
(item) => item['departmentName'] ?? '', isRequired: true),
const SizedBox(height: 16),
]
else
...[
_buildLabel('Company'),
const SizedBox(height: 8),
_buildTextField(_companyController, 'Company', readOnly: true),
const SizedBox(height: 16),
_buildLabel('Department'),
const SizedBox(height: 8),
_buildTextField(_departmentController, 'Department', readOnly: true),
const SizedBox(height: 16),
],
_buildLabel('Team'),
const SizedBox(height: 8),
_buildDropdown(_teams, _selectedTeam, 'Select Team', (val) => setState(() => _selectedTeam = val), (item) => item.toString()),
const SizedBox(height: 16),
_buildLabel('Product Name'),
const SizedBox(height: 8),
_buildDropdown(_products, _selectedProduct, 'Select Product',
(val) => setState(() {
_selectedProduct = val;
_productCategoryController.text = _selectedProduct?['category'] ?? '';
_serialNumberController.clear();
_quantityController.text = '1';
}),
(item) => item['productName'] ?? '', isRequired: true
),
const SizedBox(height: 16),
_buildLabel('Product Image'),
const SizedBox(height: 8),
_buildProductImage(),
const SizedBox(height: 16),
_buildLabel('Product Category'),
const SizedBox(height: 8),
_buildTextField(_productCategoryController, '', readOnly: true),
const SizedBox(height: 16),
if (selectedCategory != null) ...[
_buildLabel('Part Number'),
const SizedBox(height: 8),
_buildTextField(_partNumberController, 'Part Number'),
const SizedBox(height: 16),
],
if (selectedCategory == 'Asset' || selectedCategory == 'Part') ...[
_buildLabel('Serial Number'),
const SizedBox(height: 8),
_buildTextField(_serialNumberController, 'Serial Number'),
const SizedBox(height: 16),
],
if (selectedCategory == 'Disposable') ...[
_buildLabel('Quantity'),
const SizedBox(height: 8),
_buildTextField(_quantityController, 'Quantity', keyboardType: TextInputType.number, isRequired: true),
const SizedBox(height: 16),
],
_buildLabel('Supplier'),
const SizedBox(height: 8),
_buildDropdown(_suppliers, _selectedSupplier, 'Select Supplier', (val) => setState(() => _selectedSupplier = val), (item) => item['supplierCompName'] ?? ''),
const SizedBox(height: 16),
_buildLabel('Purchase Date'),
const SizedBox(height: 8),
_buildDatePicker('Purchase Date', _purchaseDate, (date) => setState(() => _purchaseDate = date)),
const SizedBox(height: 16),
_buildLabel('PO Number'),
const SizedBox(height: 8),
_buildTextField(_poNoController, 'Enter PO'),
const SizedBox(height: 16),
_buildLabel('Default Item Price'),
const SizedBox(height: 8),
_buildTextField(_defaultPriceController, 'RM0.00', keyboardType: TextInputType.number, isRequired: true),
const SizedBox(height: 16),
_buildLabel('Currency'),
const SizedBox(height: 8),
_buildDropdown(_currencies.keys.toList(), _selectedCurrency, 'Select Currency',
(val) {
setState(() {
_selectedCurrency = val;
_calculateItemPrice();
});
},
(item) => item.toString(), isRequired: true),
const SizedBox(height: 16),
_buildLabel('Currency Rate(%)'),
const SizedBox(height: 8),
_buildTextField(_currencyRateController, 'Currency Rate(%)', keyboardType: TextInputType.number, isRequired: true),
const SizedBox(height: 16),
_buildLabel('Item Price (${_selectedCurrency ?? ''})'),
const SizedBox(height: 8),
_buildTextField(_itemPriceController, 'Item Price', keyboardType: TextInputType.number, readOnly: true, isRequired: true),
const SizedBox(height: 16),
_buildLabel('DO Number'),
const SizedBox(height: 8),
_buildTextField(_doNumberController, 'Enter DO Number'),
const SizedBox(height: 16),
_buildLabel('DO Date'),
const SizedBox(height: 8),
_buildDatePicker('DO Date', _doDate, (date) {
setState(() => _doDate = date);
_calculateWarrantyEnd();
}),
const SizedBox(height: 16),
_buildLabel('Warranty (Months)'),
const SizedBox(height: 8),
_buildTextField(_warrantyController, 'Enter Warranty (Months)', keyboardType: TextInputType.number),
const SizedBox(height: 16),
_buildLabel('Warranty End'),
const SizedBox(height: 8),
_buildTextField(
TextEditingController(text: _warrantyEndDate != null ? DateFormat.yMMMd().format(_warrantyEndDate!) : 'Calculated automatically'),
'Warranty End Date',
readOnly: true,
),
const SizedBox(height: 16),
_buildLabel('Invoice Number'),
const SizedBox(height: 8),
_buildTextField(_invoiceNumberController, 'Invoice Number'),
const SizedBox(height: 16),
_buildLabel('Invoice Date'),
const SizedBox(height: 8),
_buildDatePicker('Invoice Date', _invoiceDate, (date) => setState(() => _invoiceDate = date)),
const SizedBox(height: 40),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _submitForm,
icon: _isLoading ? Container() : const Icon(Icons.save, color: Colors.white),
label: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(isEdit ? 'Update' : 'Submit', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade300,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
),
),
],
),
),
),
);
}
Widget _buildProductImage() {
String? imageUrl = _selectedProduct?['imageProduct'];
return Container(
height: 150,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: imageUrl != null && imageUrl.isNotEmpty
? Image.network(
ApiService.baseUrl + imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => const Icon(Icons.broken_image, color: Colors.grey, size: 48),
)
: const Center(child: Text('Select a product to see image', style: TextStyle(color: Colors.grey))),
);
}
Widget _buildLabel(String text) {
return Text(text, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Colors.black87));
}
Widget _buildTextField(TextEditingController controller, String hintText, {TextInputType? keyboardType, bool isRequired = false, bool readOnly = false}) {
return TextFormField(
controller: controller,
readOnly: readOnly,
decoration: InputDecoration(
hintText: hintText,
filled: true,
fillColor: readOnly ? Colors.grey.shade100 : Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
keyboardType: keyboardType,
validator: isRequired ? (value) => (value == null || value.isEmpty) ? 'This field is required' : null : null,
);
}
Widget _buildDatePicker(String label, DateTime? date, Function(DateTime) onDateChanged) {
return InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: date ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2101),
);
if (pickedDate != null && mounted) {
onDateChanged(pickedDate);
}
},
child: InputDecorator(
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixIcon: const Icon(Icons.calendar_today)
),
child: Text(date != null ? DateFormat.yMMMd().format(date) : label),
),
);
}
Widget _buildDropdown(
List<dynamic> items,
dynamic selectedItem,
String hint,
void Function(dynamic) onChanged,
String Function(dynamic) displayField, {
bool isRequired = false,
}) {
return DropdownButtonFormField<dynamic>(
value: selectedItem,
hint: Text(hint),
isExpanded: true,
items: items.map<DropdownMenuItem<dynamic>>((item) {
return DropdownMenuItem<dynamic>(
value: item,
child: Text(displayField(item)),
);
}).toList(),
onChanged: (val) => onChanged(val),
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
validator: isRequired ? (value) => (value == null) ? 'This field is required' : null : null,
);
}
}

View File

@ -0,0 +1,536 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/services/item_movement_service.dart';
import 'package:inventory_system/services/station_service.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/services/api_service.dart';
class ItemMovementAllScreen extends StatefulWidget {
const ItemMovementAllScreen({super.key});
@override
State<ItemMovementAllScreen> createState() => _ItemMovementAllScreenState();
}
class _ItemMovementAllScreenState extends State<ItemMovementAllScreen>
with SingleTickerProviderStateMixin {
int _selectedIndex = 0;
late TabController _tabController;
// Services
final ItemMovementService _itemMovementService = ItemMovementService();
final StationService _stationService = StationService();
// State
bool _isLoading = true;
String? _errorMessage;
List<Map<String, dynamic>> _allMovements = []; // This will hold the already filtered data from service
String _selectedDropdownValue = 'All';
final List<String> _dropdownOptions = ['All', 'Item', 'Station'];
int? _expandedIndex;
Key _tabViewKey = UniqueKey();
final TextEditingController _pendingSearchController = TextEditingController();
final TextEditingController _completeSearchController = TextEditingController();
final TextEditingController _assignSearchController = TextEditingController();
List<Map<String, dynamic>> _pendingFiltered = [];
List<Map<String, dynamic>> _completeFiltered = [];
List<Map<String, dynamic>> _assignFiltered = [];
@override
void initState() {
super.initState();
_fetchInitialData();
_pendingSearchController.addListener(_applyFilters);
_completeSearchController.addListener(_applyFilters);
_assignSearchController.addListener(_applyFilters);
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
if (!_tabController.indexIsChanging && _expandedIndex != null) {
setState(() {
_expandedIndex = null;
_tabViewKey = UniqueKey();
});
}
});
}
Future<void> _fetchInitialData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final movements = (await _itemMovementService.fetchItemMovements()).cast<Map<String, dynamic>>();
// Sort item movements by 'sendDate' or 'date' in descending order (latest first)
movements.sort((a, b) {
final DateTime dateA = DateTime.tryParse(a['sendDate'] ?? a['date'] ?? '') ?? DateTime(0);
final DateTime dateB = DateTime.tryParse(b['sendDate'] ?? b['date'] ?? '') ?? DateTime(0);
return dateB.compareTo(dateA); // Descending order
});
if (!mounted) return;
setState(() {
_allMovements = movements;
_applyFilters();
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Failed to load data: $e';
_isLoading = false;
});
debugPrint('Error fetching initial data for ItemMovementAllScreen: $e');
}
}
@override
void dispose() {
_pendingSearchController.removeListener(_applyFilters);
_completeSearchController.removeListener(_applyFilters);
_assignSearchController.removeListener(_applyFilters);
_pendingSearchController.dispose();
_completeSearchController.dispose();
_assignSearchController.dispose();
_tabController.dispose();
super.dispose();
}
void _applyFilters() {
final pendingQuery = _pendingSearchController.text.toLowerCase();
final completeQuery = _completeSearchController.text.toLowerCase();
final assignQuery = _assignSearchController.text.toLowerCase();
setState(() {
_pendingFiltered = _allMovements
.where((m) {
final isComplete = m['movementComplete'] == true || m['movementComplete'] == 1;
return !isComplete && _matchesQuery(m, pendingQuery);
})
.toList();
_completeFiltered = _allMovements
.where((m) {
final isComplete = m['movementComplete'] == true || m['movementComplete'] == 1;
return isComplete && m['action'] != 'Assign' && _matchesQuery(m, completeQuery);
})
.toList();
_assignFiltered = _allMovements
.where((m) => m['action'] == 'Assign' && _matchesQuery(m, assignQuery))
.toList();
_expandedIndex = null;
});
}
bool _matchesQuery(Map<String, dynamic> m, String q) {
if (q.isEmpty) return true;
final keys = [
'productName', 'uniqueID', 'action', 'productCategory', 'latestStatus',
'toStationName', 'lastStationName', 'toStoreName', 'lastStoreName',
'toUserName', 'lastUserName', 'remark', 'consignmentNote', 'id'
];
for (final k in keys) {
final v = m[k]?.toString().toLowerCase();
if (v != null && v.contains(q)) return true;
}
return false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: TitleBar(
title: 'Item Movement',
actions: [
Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedDropdownValue,
icon: const Icon(Icons.keyboard_arrow_down),
items: _dropdownOptions.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (newValue) {
if (newValue == 'Item') {
Navigator.pushNamed(context, '/item_movement_item');
}
if (newValue == 'Station') {
Navigator.pushNamed(context, '/item_movement_station');
}
},
),
),
),
],
),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.itemMovement),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Padding(padding: const EdgeInsets.all(16.0), child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontSize: 16), textAlign: TextAlign.center)))
: RefreshIndicator(
onRefresh: _fetchInitialData,
child: Column(
children: [
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
onTap: (index) {
setState(() {
_expandedIndex = null;
_tabViewKey = UniqueKey();
});
},
labelColor: Colors.purple.shade800,
unselectedLabelColor: Colors.grey.shade600,
indicatorColor: Colors.purple.shade800,
indicatorWeight: 3,
tabs: const [
Tab(text: 'Pending Item'),
Tab(text: 'Complete Item'),
Tab(text: 'Assign Station'),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
key: _tabViewKey,
children: [
_buildTabPage(
controller: _pendingSearchController,
items: _pendingFiltered,
tabType: 'pending',
),
_buildTabPage(
controller: _completeSearchController,
items: _completeFiltered,
tabType: 'complete',
),
_buildTabPage(
controller: _assignSearchController,
items: _assignFiltered,
tabType: 'assign',
),
],
),
),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) {
Navigator.pop(context);
} else if (index == 1) {
if (mounted) {
Navigator.pushNamed(context, '/scan');
}
} else {
setState(() {
_selectedIndex = index;
});
}
},
),
);
}
Widget _buildTabPage({
required TextEditingController controller,
required List<Map<String, dynamic>> items,
required String tabType,
}) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: controller,
onChanged: (_) => _applyFilters(),
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.clear();
_applyFilters();
},
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 16),
Expanded(
child: items.isEmpty
? const Center(child: Text('No items to display.'))
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return _buildItemCard(item, index, tabType);
},
),
),
],
),
);
}
Widget _buildItemCard(Map<String, dynamic> item, int index, String tabType) {
final bool isExpanded = _expandedIndex == index;
final String imageUrl = item['productImage'] ?? '';
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: imageUrl.isNotEmpty
? Image.network(
ApiService.baseUrl + imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => const Icon(Icons.broken_image, color: Colors.grey),
)
: const Icon(Icons.image_not_supported, color: Colors.grey),
),
const SizedBox(height: 6),
Text(
item['uniqueID'] ?? '-',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['productName'] ?? '-',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text('ID: ${item['id'] ?? '-'}', style: TextStyle(color: Colors.grey.shade600)),
const SizedBox(height: 2),
Text(item['sendDate'] ?? item['date'] ?? '-', style: TextStyle(color: Colors.grey.shade600)),
],
),
),
],
),
),
InkWell(
onTap: () {
setState(() {
_expandedIndex = isExpanded ? null : index;
});
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
isExpanded ? 'Less Details' : 'More Details',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade600,
),
),
const SizedBox(width: 4),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
color: Colors.blue.shade600,
size: 20,
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isExpanded
? _buildDetailsSection(item, tabType)
: const SizedBox.shrink(),
),
],
),
),
);
}
Widget _buildDetailsSection(Map<String, dynamic> item, String tabType) {
List<Widget> tiles = [];
String? note = item['remark'];
switch (tabType) {
case 'pending':
tiles = [
_buildDetailItem('Action', item['action'] ?? 'N/A'),
_buildDetailItem('Product Category', item['productCategory'] ?? 'N/A'),
_buildDetailItem('Status', item['latestStatus'] ?? 'N/A'),
_buildDetailItem('From Station', item['lastStationName'] ?? 'N/A'),
_buildDetailItem('From Store', item['lastStoreName'] ?? 'N/A'),
_buildDetailItem('Quantity', item['quantity']?.toString() ?? 'N/A'),
_buildDetailItem('Last User', item['lastUserName'] ?? 'N/A'),
_buildDetailItem('From User', item['toUserName'] ?? 'N/A'),
];
break;
case 'assign':
tiles = [
_buildDetailItem('Action', item['action'] ?? 'N/A'),
_buildDetailItem('Station User PIC', item['toUserName'] ?? 'N/A'),
_buildDetailItem('Quantity', item['quantity']?.toString() ?? 'N/A'),
_buildDetailItem('From Station', item['lastStationName'] ?? 'N/A'),
_buildDetailItem('To Station', item['toStationName'] ?? 'N/A'),
_buildDetailItem('Last Store', item['lastStoreName'] ?? 'N/A'),
];
note = item['remark'];
break;
case 'complete':
tiles = [
_buildDetailItem('Action', item['action'] ?? 'N/A'),
_buildDetailItem('Product Category', item['productCategory'] ?? 'N/A'),
_buildDetailItem('Quantity', item['quantity']?.toString() ?? 'N/A'),
_buildDetailItem('From Station', item['lastStationName'] ?? 'N/A'),
_buildDetailItem('To Station', item['toStationName'] ?? 'N/A'),
_buildDetailItem('Last User', item['lastUserName'] ?? 'N/A'),
_buildDetailItem('From User', item['toUserName'] ?? 'N/A'),
_buildDetailItem('From Store', item['lastStoreName'] ?? 'N/A'),
_buildDetailItem('To Store', item['toStoreName'] ?? 'N/A'),
_buildDetailItem('Latest Status', item['latestStatus'] ?? 'N/A'),
];
break;
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: LayoutBuilder(
builder: (context, constraints) {
const double spacing = 16;
final double tileWidth = (constraints.maxWidth - (spacing * 2)) / 3;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: spacing,
runSpacing: 12,
children: tiles
.map((w) => SizedBox(width: tileWidth, child: w))
.toList(),
),
if (note != null && note.toString().trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'Note / Remark',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
note,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
],
);
},
),
);
}
Widget _buildDetailItem(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
softWrap: true,
),
],
);
}
}

View File

@ -0,0 +1,641 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/services/item_movement_service.dart';
import 'package:inventory_system/services/station_service.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:collection/collection.dart';
import 'package:timeline_tile/timeline_tile.dart';
import 'package:inventory_system/services/api_service.dart'; // Import ApiService for Image.network base URL
class ItemMovementItemScreen extends StatefulWidget {
const ItemMovementItemScreen({super.key});
@override
State<ItemMovementItemScreen> createState() => _ItemMovementItemScreenState();
}
class _ItemMovementItemScreenState extends State<ItemMovementItemScreen> {
// Services
final ItemMovementService _itemMovementService = ItemMovementService();
final StationService _stationService = StationService();
// final UserService _userService = UserService(); // No longer used for data fetching
// State
bool _isLoading = true;
String? _errorMessage;
List<Map<String, dynamic>> _groupedMovements = [];
List<Map<String, dynamic>> _filteredMovements = [];
String _selectedDropdownValue = 'Item';
final List<String> _dropdownOptions = ['All', 'Item', 'Station'];
final TextEditingController _searchController = TextEditingController();
int _selectedIndex = 0;
int? _expandedItemIndex;
final Set<String> _expandedMovements = {};
final Set<String> _showFullHistory = {};
@override
void initState() {
super.initState();
_fetchInitialData();
_searchController.addListener(_applyFilter);
}
Future<void> _fetchInitialData() async {
try {
// ItemMovementService now handles role-based filtering, so we directly get the relevant movements
final movements = (await _itemMovementService.fetchItemMovements()).cast<Map<String, dynamic>>();
if (!mounted) return;
// Group movements by uniqueID
final grouped = groupBy(movements, (Map<String, dynamic> m) => m['uniqueID']);
List<Map<String, dynamic>> processedGroupedMovements = grouped.entries.map((entry) {
final history = entry.value;
// Sort history by date within each group (latest first)
history.sort((a, b) {
final dateA = DateTime.tryParse(a['sendDate'] ?? a['date'] ?? '');
final dateB = DateTime.tryParse(b['sendDate'] ?? b['date'] ?? '');
if (dateA == null && dateB == null) return 0;
if (dateA == null) return 1;
if (dateB == null) return -1;
return dateB.compareTo(dateA);
});
return {
'pid': entry.key,
'history': history,
};
}).toList();
// Sort the grouped movements themselves by the latest movement's date
processedGroupedMovements.sort((a, b) {
final latestA = (a['history'] as List<dynamic>).first;
final latestB = (b['history'] as List<dynamic>).first;
final dateA = DateTime.tryParse(latestA['sendDate'] ?? latestA['date'] ?? '') ?? DateTime(0);
final dateB = DateTime.tryParse(latestB['sendDate'] ?? latestB['date'] ?? '') ?? DateTime(0);
return dateB.compareTo(dateA); // Descending order
});
setState(() {
_groupedMovements = processedGroupedMovements;
_filteredMovements = List.of(_groupedMovements);
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Failed to load data: $e';
_isLoading = false;
});
debugPrint('Error fetching initial data for ItemMovementItemScreen: $e');
}
}
@override
void dispose() {
_searchController.removeListener(_applyFilter);
_searchController.dispose();
super.dispose();
}
void _applyFilter() {
final query = _searchController.text.trim().toLowerCase();
setState(() {
_filteredMovements = _groupedMovements
.where((item) => (item['pid'] as String?)?.toLowerCase().contains(query) ?? false)
.toList();
_expandedItemIndex = null;
_expandedMovements.clear();
_showFullHistory.clear();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: TitleBar(
title: 'Item Movement',
actions: [_buildDropdown()],
),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.itemMovement),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Padding(padding: const EdgeInsets.all(16.0), child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontSize: 16), textAlign: TextAlign.center)))
: RefreshIndicator(
onRefresh: _fetchInitialData,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
children: [
_buildSearchBar(),
const SizedBox(height: 16),
Expanded(
child: _filteredMovements.isEmpty
? const Center(child: Text('No items found.'))
: ListView.builder(
itemCount: _filteredMovements.length,
itemBuilder: (context, index) {
final item = _filteredMovements[index];
final isExpanded = _expandedItemIndex == index;
return _buildItemCard(item, index, isExpanded);
},
),
),
],
),
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) Navigator.pop(context);
else if (index == 1) Navigator.pushNamed(context, '/scan');
else setState(() => _selectedIndex = index);
},
),
);
}
Widget _buildExpandedCardContent(Map<String, dynamic> item) {
final List<dynamic> history = item['history'];
final latestMovement = history.first as Map<String, dynamic>;
final pid = item['pid'] as String;
final bool isHistoryVisible = _showFullHistory.contains(pid);
return Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Latest Movement',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
_buildMovementDetailCard(latestMovement),
const SizedBox(height: 12),
if (history.length > 1)
InkWell(
onTap: () {
setState(() {
if (isHistoryVisible) {
_showFullHistory.remove(pid);
} else {
_showFullHistory.add(pid);
}
});
},
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isHistoryVisible ? 'Hide History' : 'View History',
style: TextStyle(color: Colors.grey.shade800, fontWeight: FontWeight.w600),
),
const SizedBox(width: 4),
Icon(
isHistoryVisible ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 18,
color: Colors.grey.shade800,
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isHistoryVisible ? _buildHistoryTimeline(history) : const SizedBox.shrink(),
),
],
),
);
}
Widget _buildHistoryTimeline(List<dynamic> history) {
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Column(
children: List.generate(history.length, (index) {
final event = history[index] as Map<String, dynamic>;
return TimelineTile(
alignment: TimelineAlign.manual,
lineXY: 0.1,
isFirst: index == 0,
isLast: index == history.length - 1,
beforeLineStyle: const LineStyle(color: Colors.blue, thickness: 2),
indicatorStyle: IndicatorStyle(
width: 20,
color: Colors.blue,
indicator: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(color: Colors.blue, width: 2),
),
child: index == 0 ? null : const Center(child: CircleAvatar(radius: 5, backgroundColor: Colors.blue)),
),
),
endChild: Container(
constraints: const BoxConstraints(minHeight: 80),
margin: const EdgeInsets.only(left: 16, bottom: 16, right: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event['date'] ?? 'N/A',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
const SizedBox(height: 6),
Text(
'Status: ${event['latestStatus'] ?? 'N/A'}',
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.black87),
),
],
),
),
);
}),
),
);
}
Widget _buildDropdown() {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedDropdownValue,
icon: const Icon(Icons.keyboard_arrow_down),
items: _dropdownOptions.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (newValue) {
if (newValue == 'All') {
Navigator.pushReplacementNamed(context, '/item_movement_all');
}
if (newValue == 'Station') {
Navigator.pushReplacementNamed(context, '/item_movement_station');
}
},
),
),
);
}
Widget _buildSearchBar() {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search by item code',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
);
}
Widget _buildItemCard(Map<String, dynamic> item, int index, bool isExpanded) {
final headerBg = isExpanded ? Colors.blue.shade50 : Colors.white;
final headerBorder = isExpanded ? Colors.blue.shade200 : Colors.grey.shade300;
final pid = item['pid'];
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: headerBorder),
),
child: Column(
children: [
InkWell(
onTap: () {
setState(() {
_expandedItemIndex = isExpanded ? null : index;
if (!isExpanded) {
_showFullHistory.remove(pid);
}
});
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: headerBg,
borderRadius: const BorderRadius.vertical(top: Radius.circular(11)),
border: Border(bottom: BorderSide(color: headerBorder)),
),
child: Row(
children: [
Expanded(
child: Text(
'Item : $pid',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
Text(
isExpanded ? 'Hide Details' : 'Show Details',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isExpanded ? Colors.blue.shade700 : Colors.grey.shade700,
),
),
const SizedBox(width: 4),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 20,
color: isExpanded ? Colors.blue.shade700 : Colors.grey.shade700,
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isExpanded ? _buildExpandedCardContent(item) : const SizedBox.shrink(),
),
],
),
);
}
Widget _buildMovementDetailCard(Map<String, dynamic> movement) {
final statusColor = _statusColor(movement['action'] ?? '');
final movementId = movement['id'].toString();
final bool isInnerExpanded = _expandedMovements.contains(movementId);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
movement['action'] ?? '-',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
),
_chip(movement['latestStatus'] ?? 'N/A', Colors.green.shade700),
const SizedBox(width: 8),
_outlinedButton(
isInnerExpanded ? 'Hide Details' : 'Show Details',
Colors.blue.shade700,
onTap: () {
setState(() {
if (isInnerExpanded) {
_expandedMovements.remove(movementId);
} else {
_expandedMovements.add(movementId);
}
});
},
),
],
),
const SizedBox(height: 12),
_kvGrid('Send Date', movement['action'] == 'Register' ? movement['date'] : movement['sendDate'], 'Receive Date', movement['receiveDate']),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isInnerExpanded
? Column(
children: [
const SizedBox(height: 16),
_buildMovementFlow(movement),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _actionButton('Remark', isPrimary: true)),
const SizedBox(width: 12),
Expanded(child: _actionButton('Consignment Note')),
],
),
],
)
: const SizedBox.shrink(),
),
],
),
);
}
Widget _buildMovementFlow(Map<String, dynamic> movement) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_flowStep(Icons.person, 'Start'),
Column(
children: [
const Icon(Icons.arrow_forward, size: 20, color: Colors.black54),
const SizedBox(height: 4),
Text(
movement['latestStatus'] ?? '',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
_flowStep(Icons.factory, 'End'),
],
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
children: [
_kvFlow('Last User:', movement['lastUserName']),
_kvFlow('Station:', movement['lastStationName']),
_kvFlow('Store:', movement['lastStoreName']),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
children: [
_kvFlow('From User:', movement['toUserName']),
_kvFlow('Station:', movement['toStationName']),
_kvFlow('Store:', movement['toStoreName']),
],
),
),
],
),
],
),
);
}
Color _statusColor(String status) {
switch (status.toLowerCase()) {
case 'stock out': return const Color(0xFFD32F2F);
case 'stock in': return const Color(0xFF1976D2);
case 'assign': return const Color(0xFF2E7D32);
default: return Colors.grey.shade800;
}
}
Widget _flowStep(IconData icon, String label) {
return Column(
children: [
Icon(icon, size: 28, color: Colors.blueGrey),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
],
);
}
Widget _kvGrid(String label1, String? value1, String label2, String? value2) {
return Row(
children: [
Expanded(child: _kv(label1, value1)),
Expanded(child: _kv(label2, value2)),
],
);
}
Widget _kv(String label, String? value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const SizedBox(height: 2),
Text(
value ?? '-',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
);
}
Widget _kvFlow(String label, String? value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
const SizedBox(width: 4),
Expanded(
child: Text(
value ?? '-',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
);
}
Widget _chip(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.bold),
),
);
}
Widget _outlinedButton(String text, Color color, {VoidCallback? onTap}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withOpacity(0.7)),
),
child: Text(
text,
style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.bold),
),
),
);
}
Widget _actionButton(String text, {bool isPrimary = false}) {
return ElevatedButton(
onPressed: () {},
style: isPrimary
? ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade600,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
)
: OutlinedButton.styleFrom(
foregroundColor: Colors.blue.shade700,
side: BorderSide(color: Colors.blue.shade300),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(text),
);
}
}

View File

@ -0,0 +1,343 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/services/item_movement_service.dart';
import 'package:inventory_system/services/station_service.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:collection/collection.dart';
// import 'package:inventory_system/services/session_manager.dart'; // No longer needed for client-side filtering
import 'package:intl/intl.dart';
class ItemMovementStationScreen extends StatefulWidget {
const ItemMovementStationScreen({super.key});
@override
State<ItemMovementStationScreen> createState() => _ItemMovementStationScreenState();
}
class _ItemMovementStationScreenState extends State<ItemMovementStationScreen> {
// Services
final ItemMovementService _itemMovementService = ItemMovementService();
final StationService _stationService = StationService();
// State
bool _isLoading = true;
String? _errorMessage;
List<Map<String, dynamic>> _stationsData = [];
List<Map<String, dynamic>> _filteredStations = [];
String _selectedDropdownValue = 'Station';
final List<String> _dropdownOptions = ['All', 'Item', 'Station'];
final TextEditingController _searchController = TextEditingController();
int _selectedIndex = 0;
int? _expandedIndex;
@override
void initState() {
super.initState();
_fetchInitialData();
_searchController.addListener(_applyFilter);
}
Future<void> _fetchInitialData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// ItemMovementService now handles role-based filtering, so we directly get the relevant movements
final movements = (await _itemMovementService.fetchItemMovements()).cast<Map<String, dynamic>>();
if (!mounted) return;
// Group movements by item to find the latest status
final groupedByItem = groupBy(movements, (Map<String, dynamic> m) => m['uniqueID']);
final latestMovements = groupedByItem.values.map((itemMovements) {
itemMovements.sort((a, b) {
final dateA = DateTime.tryParse(a['date'] ?? '');
final dateB = DateTime.tryParse(b['date'] ?? '');
if (dateA == null && dateB == null) return 0;
if (dateA == null) return 1;
if (dateB == null) return -1;
return dateB.compareTo(dateA);
});
return itemMovements.first;
}).toList();
// Group the relevant items by their current station
final groupedByStation = groupBy(latestMovements, (Map<String, dynamic> m) => m['toStationName'] ?? 'Unassigned');
List<Map<String, dynamic>> processedStations = groupedByStation.entries.map((entry) {
// Sort items within each station by date
final List<Map<String, dynamic>> itemsInStation = entry.value.map((m) {
return {
'code': m['uniqueID'] ?? 'N/A',
'description': m['productName'] ?? 'N/A',
'date': m['date'] ?? 'N/A',
};
}).toList();
itemsInStation.sort((a, b) {
final dateA = DateTime.tryParse(a['date'] ?? '') ?? DateTime(0);
final dateB = DateTime.tryParse(b['date'] ?? '') ?? DateTime(0);
return dateB.compareTo(dateA); // Descending order
});
return {
'stationName': entry.key,
'id': entry.key, // Use name as unique ID for state
'items': itemsInStation,
};
}).toList();
// Sort stations themselves by the date of the latest item within each station (descending)
processedStations.sort((a, b) {
final latestItemA = (a['items'] as List<dynamic>).first;
final latestItemB = (b['items'] as List<dynamic>).first;
final dateA = DateTime.tryParse(latestItemA['date'] ?? '') ?? DateTime(0);
final dateB = DateTime.tryParse(latestItemB['date'] ?? '') ?? DateTime(0);
return dateB.compareTo(dateA);
});
setState(() {
_stationsData = processedStations;
_filteredStations = List.of(_stationsData);
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Failed to load data: $e';
_isLoading = false;
});
debugPrint('Error fetching initial data for ItemMovementStationScreen: $e');
}
}
@override
void dispose() {
_searchController.removeListener(_applyFilter);
_searchController.dispose();
super.dispose();
}
void _applyFilter() {
final query = _searchController.text.trim().toLowerCase();
setState(() {
_filteredStations = _stationsData
.where((station) => (station['stationName'] as String).toLowerCase().contains(query))
.toList();
_expandedIndex = null;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: TitleBar(
title: 'Item Movement',
actions: [_buildDropdown()],
),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.itemMovement),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Padding(padding: const EdgeInsets.all(16.0), child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontSize: 16), textAlign: TextAlign.center)))
: RefreshIndicator(
onRefresh: _fetchInitialData,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
children: [
_buildSearchBar(),
const SizedBox(height: 16),
Expanded(
child: _filteredStations.isEmpty
? const Center(child: Text('No stations found.'))
: ListView.builder(
itemCount: _filteredStations.length,
itemBuilder: (context, index) {
final station = _filteredStations[index];
final isExpanded = _expandedIndex == index;
return _buildStationCard(station, index, isExpanded);
},
),
),
],
),
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) Navigator.pop(context);
else if (index == 1) Navigator.pushNamed(context, '/scan');
else setState(() => _selectedIndex = index);
},
),
);
}
Widget _buildStationCard(Map<String, dynamic> station, int index, bool isExpanded) {
final headerBg = isExpanded ? Colors.blue.shade50 : Colors.white;
final headerBorder = isExpanded ? Colors.blue.shade200 : Colors.grey.shade300;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: headerBorder),
),
child: Column(
children: [
InkWell(
onTap: () {
setState(() {
_expandedIndex = isExpanded ? null : index;
});
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: headerBg,
borderRadius: isExpanded
? const BorderRadius.vertical(top: Radius.circular(11))
: BorderRadius.circular(11),
),
child: Row(
children: [
const Icon(Icons.location_on_outlined, color: Colors.black54),
const SizedBox(width: 12),
Expanded(
child: Text(
station['stationName']!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
isExpanded ? 'Hide' : 'Show',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isExpanded ? Colors.blue.shade700 : Colors.grey.shade700,
),
),
const SizedBox(width: 4),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 20,
color: isExpanded ? Colors.blue.shade700 : Colors.grey.shade700,
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isExpanded ? _buildExpandedTable(station) : const SizedBox.shrink(),
),
],
),
);
}
Widget _buildExpandedTable(Map<String, dynamic> station) {
final List<dynamic> items = station['items'];
return Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
child: DataTable(
columnSpacing: 20,
headingRowHeight: 40,
dataRowMinHeight: 48,
dataRowMaxHeight: 56,
headingTextStyle: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.black87,
),
columns: const [
DataColumn(label: Text('Code')),
DataColumn(label: Text('Description')),
DataColumn(label: Text('Date')),
],
rows: items.map((item) {
final dateString = item['date'] as String?;
final formattedDate = dateString != null && dateString.isNotEmpty
? DateFormat('yyyy-MM-dd').format(DateTime.parse(dateString))
: 'N/A';
return DataRow(
cells: [
DataCell(Text(item['code']!)),
DataCell(Text(item['description']!)),
DataCell(Text(formattedDate)),
],
);
}).toList(),
),
);
}
Widget _buildDropdown() {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedDropdownValue,
icon: const Icon(Icons.keyboard_arrow_down),
items: _dropdownOptions.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (newValue) {
if (newValue == 'All') {
Navigator.pushReplacementNamed(context, '/item_movement_all');
}
if (newValue == 'Item') {
Navigator.pushReplacementNamed(context, '/item_movement_item');
}
},
),
),
);
}
Widget _buildSearchBar() {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search by station...',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
);
}
}

View File

@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:inventory_system/services/manufacturer_service.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/title_bar.dart';
class ManufacturerScreen extends StatefulWidget {
const ManufacturerScreen({super.key});
@override
State<ManufacturerScreen> createState() => _ManufacturerScreenState();
}
class _ManufacturerScreenState extends State<ManufacturerScreen> {
int _selectedIndex = 0;
final ManufacturerService _manufacturerService = ManufacturerService();
late Future<List<dynamic>> _manufacturersFuture;
List<dynamic> _allManufacturers = [];
List<dynamic> _filteredManufacturers = [];
final TextEditingController _searchController = TextEditingController();
final TextEditingController _formController = TextEditingController();
@override
void initState() {
super.initState();
_manufacturersFuture = _fetchManufacturers();
_searchController.addListener(_onSearchChanged);
}
Future<List<dynamic>> _fetchManufacturers() async {
try {
final manufacturers = await _manufacturerService.fetchManufacturers();
manufacturers.sort((a, b) {
final nameA = a['manufacturerName']?.toString().toLowerCase() ?? '';
final nameB = b['manufacturerName']?.toString().toLowerCase() ?? '';
return nameA.compareTo(nameB);
});
if (mounted) {
setState(() {
_allManufacturers = manufacturers;
_filteredManufacturers = manufacturers;
});
}
return manufacturers;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load manufacturers: $e'), backgroundColor: Colors.red),
);
}
rethrow;
}
}
@override
void dispose() {
_searchController.dispose();
_formController.dispose();
super.dispose();
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredManufacturers = _allManufacturers.where((manufacturer) {
final name = manufacturer['manufacturerName']?.toString().toLowerCase() ?? '';
return name.contains(query);
}).toList();
});
}
void _showFormDialog({Map<String, dynamic>? manufacturer}) {
final isEdit = manufacturer != null;
_formController.text = isEdit ? manufacturer['manufacturerName'] : '';
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text(isEdit ? 'Edit Manufacturer' : 'Add Manufacturer'),
content: TextField(
controller: _formController,
autofocus: true,
decoration: InputDecoration(
labelText: 'Manufacturer Name',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () => _submitForm(isEdit, manufacturer?['manufacturerId']),
child: const Text('Save'),
),
],
),
);
}
void _submitForm(bool isEdit, int? id) async {
if (_formController.text.isEmpty) return;
Navigator.pop(context);
final String newName = _formController.text;
try {
if (isEdit) {
await _manufacturerService.updateManufacturer(id!, newName);
} else {
await _manufacturerService.addManufacturer(newName);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isEdit ? 'Manufacturer updated' : 'Manufacturer added'),
backgroundColor: Colors.green,
),
);
_fetchManufacturers();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Operation failed: $e'),
backgroundColor: Colors.red,
),
);
}
}
void _confirmDelete(int id, String name) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete Manufacturer'),
content: Text('Are you sure you want to delete $name?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
try {
await _manufacturerService.deleteManufacturer(id);
Navigator.pop(dialogContext);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Manufacturer deleted successfully'),
backgroundColor: Colors.green,
),
);
await _fetchManufacturers(); // Refresh only on success
} catch (e) {
Navigator.pop(dialogContext);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Deletion failed: $e'),
backgroundColor: Colors.red,
),
);
}
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: const TitleBar(title: 'Manufacturer'),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.manufacturer), // Placeholder userName
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
ElevatedButton.icon(
onPressed: _showFormDialog,
icon: const Icon(Icons.add, size: 18),
label: const Text('Add Manufacturer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade100,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchManufacturers,
child: FutureBuilder<List<dynamic>>(
future: _manufacturersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting && _allManufacturers.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && _allManufacturers.isEmpty) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (_filteredManufacturers.isEmpty) {
return const Center(child: Text('No manufacturers found.'));
}
return ListView.builder(
itemCount: _filteredManufacturers.length,
itemBuilder: (context, index) {
final manufacturer = _filteredManufacturers[index];
final name = manufacturer['manufacturerName'] ?? 'No Name';
final id = manufacturer['manufacturerId'];
return Slidable(
key: Key(id.toString()),
endActionPane: ActionPane(
motion: const StretchMotion(),
children: [
SlidableAction(
onPressed: (context) => _showFormDialog(manufacturer: manufacturer),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
icon: Icons.edit,
label: 'Edit',
),
SlidableAction(
onPressed: (context) => _confirmDelete(id, name),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete,
label: 'Delete',
),
],
),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
color: Colors.white,
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade300, width: 1),
),
child: ListTile(
title: Text(
name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
),
);
},
);
},
),
),
),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) {
Navigator.pop(context);
} else {
setState(() {
_selectedIndex = index;
});
}
},
),
);
}
}

View File

@ -0,0 +1,414 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:inventory_system/services/product_service.dart';
import 'package:inventory_system/screens/admin/product/product_form.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/title_bar.dart';
class ProductScreen extends StatefulWidget {
const ProductScreen({super.key});
@override
State<ProductScreen> createState() => _ProductScreenState();
}
class _ProductScreenState extends State<ProductScreen> {
int _selectedIndex = 0;
final ProductService _productService = ProductService();
late Future<List<dynamic>> _productsFuture;
String _selectedFilter = 'All';
final List<String> _filters = ['All', 'Asset', 'Disposable', 'Part'];
List<dynamic> _allProducts = [];
List<dynamic> _filteredProducts = [];
final TextEditingController _searchController = TextEditingController();
int? _expandedIndex;
@override
void initState() {
super.initState();
_productsFuture = _fetchProducts();
_searchController.addListener(_applyFilters);
}
Future<List<dynamic>> _fetchProducts() async {
try {
final products = await _productService.fetchProducts();
// This sort function now uses the correct key 'productName'
products.sort((a, b) {
final nameA = a['productName'] as String? ?? '';
final nameB = b['productName'] as String? ?? '';
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
});
if (mounted) {
setState(() {
_allProducts = products;
_applyFilters(); // This will use the new sorted list
});
}
return products;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load products: $e'), backgroundColor: Colors.red),
);
}
rethrow;
}
}
@override
void dispose() {
_searchController.removeListener(_applyFilters);
_searchController.dispose();
super.dispose();
}
void _applyFilters() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredProducts = _allProducts.where((product) {
final name = product['productName'].toString().toLowerCase();
final model = product['modelNo'].toString().toLowerCase();
final category = (product['category'] ?? '').toString().toLowerCase();
final matchesSearch = name.contains(query) || model.contains(query);
final matchesFilter = _selectedFilter == 'All' || category == _selectedFilter.toLowerCase();
return matchesSearch && matchesFilter;
}).toList();
_expandedIndex = null;
});
}
void _handleDelete(int productId, String productName) async {
try {
await _productService.deleteProduct(productId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Product deleted successfully'), backgroundColor: Colors.green),
);
_fetchProducts();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete product: $e'), backgroundColor: Colors.red),
);
}
}
}
void _confirmDelete(int productId, String productName) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text('Delete Product'),
content: Text('Are you sure you want to delete $productName?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(
onPressed: () {
Navigator.pop(context);
_handleDelete(productId, productName);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: const TitleBar(title: 'Product'),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.product),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ProductFormScreen()),
).then((success) {
if (success == true) _fetchProducts();
});
},
icon: const Icon(Icons.add, size: 18),
label: const Text('Add Product'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade100,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final filter = _filters[index];
final isSelected = _selectedFilter == filter;
return ChoiceChip(
label: Text(filter),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() {
_selectedFilter = filter;
_applyFilters();
});
}
},
backgroundColor: Colors.white,
selectedColor: Colors.purple.shade100,
labelStyle: TextStyle(color: isSelected ? Colors.purple.shade900 : Colors.black54, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18), side: BorderSide(color: isSelected ? Colors.purple.shade100 : Colors.grey.shade300)),
);
},
),
),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchProducts,
child: FutureBuilder<List<dynamic>>(
future: _productsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting && _allProducts.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && _allProducts.isEmpty) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (_filteredProducts.isEmpty) {
return const Center(child: Text('No products found.'));
}
return ListView.builder(
itemCount: _filteredProducts.length,
itemBuilder: (context, index) {
final product = _filteredProducts[index];
final isExpanded = _expandedIndex == index;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Slidable(
key: Key(product['productId'].toString()),
endActionPane: ActionPane(
motion: const ScrollMotion(),
extentRatio: 0.45,
children: [
SlidableAction(
onPressed: (context) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ProductFormScreen(product: product)),
).then((success) {
if (success == true) _fetchProducts();
});
},
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
label: 'Edit',
icon: Icons.edit,
),
SlidableAction(
onPressed: (context) => _confirmDelete(product['productId'], product['productName']),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
label: 'Delete',
icon: Icons.delete,
),
],
),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300, width: 1),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Builder(
builder: (context) {
// 1. Get the relative image path from the API response
final String? imagePath = product['imageProduct'];
// 2. Construct the full URL
// We get the base URL from your environment variables
final String baseUrl = 'https://dev9.pstw.com.my';
final String fullImageUrl = (imagePath != null && imagePath.isNotEmpty) ? baseUrl + imagePath : '';
return Container(
width: 80,
height: 80,
clipBehavior: Clip.antiAlias, // Ensures the image respects the border radius
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
// 3. Conditionally display the image or a placeholder
child: (fullImageUrl.isNotEmpty)
// If a valid URL exists, show the network image
? Image.network(
fullImageUrl,
fit: BoxFit.cover, // Ensures the image fills the container
// Shows a loading spinner while the image is fetching
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
},
// Shows an error icon if the image fails to load
errorBuilder: (context, error, stackTrace) {
// Log the error for debugging
debugPrint('Failed to load image: $fullImageUrl, Error: $error');
return const Icon(Icons.broken_image, color: Colors.grey, size: 40);
},
)
// If no URL, show a placeholder icon
: const Icon(Icons.image_not_supported, color: Colors.grey, size: 40),
);
},
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product['productName'] ?? 'N/A', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(product['productShortName'] ?? 'N/A', style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontStyle: FontStyle.italic)),
],
),
),
],
),
),
InkWell(
onTap: () => setState(() => _expandedIndex = isExpanded ? null : index),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(isExpanded ? 'Less Details' : 'More Details', style: TextStyle(fontWeight: FontWeight.w600, color: Colors.blue.shade600)),
const SizedBox(width: 4),
Icon(isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, color: Colors.blue.shade600, size: 20),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isExpanded
? Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: GridView(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 12,
childAspectRatio: 2.5,
),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildDetailItem('Model No', product['modelNo'] ?? 'N/A'),
_buildDetailItem('Manufacturer', product['manufacturer']?['manufacturerName'] ?? 'N/A'),
_buildDetailItem('Category', product['category'] ?? 'N/A'),
_buildDetailItem('Quantity', product['quantityProduct']?.toString() ?? '0'),
],
),
)
: const SizedBox.shrink(),
),
],
),
),
),
);
},
);
},
),
),
),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) {
Navigator.pop(context);
} else {
setState(() {
_selectedIndex = index;
});
}
},
),
);
}
Widget _buildDetailItem(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black87)),
],
);
}
}

View File

@ -0,0 +1,372 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:inventory_system/services/product_service.dart';
class ProductFormScreen extends StatefulWidget {
final Map<String, dynamic>? product;
const ProductFormScreen({super.key, this.product});
@override
State<ProductFormScreen> createState() => _ProductFormScreenState();
}
class _ProductFormScreenState extends State<ProductFormScreen> {
final _formKey = GlobalKey<FormState>();
final _productService = ProductService();
bool _isLoading = false;
late TextEditingController _nameController;
late TextEditingController _shortNameController;
late TextEditingController _modelNoController;
String? _selectedCategory;
final List<String> _categories = ['Asset', 'Disposable', 'Part'];
List<dynamic> _manufacturers = [];
dynamic _selectedManufacturer;
File? _imageFile;
String? _base64Image;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.product?['productName']?.toString());
_shortNameController = TextEditingController(text: widget.product?['productShortName']?.toString());
_modelNoController = TextEditingController(text: widget.product?['modelNo']?.toString());
_selectedCategory = widget.product?['category'];
_fetchDropdownData();
}
Future<void> _fetchDropdownData() async {
try {
final manufacturers = await _productService.fetchManufacturers();
if (!mounted) return;
setState(() {
_manufacturers = manufacturers;
if (widget.product != null) {
final manufacturerId = widget.product!['manufacturerId'];
_selectedManufacturer = _manufacturers.firstWhere((m) => m['manufacturerId'] == manufacturerId, orElse: () => null);
}
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load manufacturers: $e'), backgroundColor: Colors.red),
);
}
}
}
Future<void> _pickImage() async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(source: ImageSource.gallery, imageQuality: 70, maxWidth: 800);
if (pickedFile != null) {
final bytes = await pickedFile.readAsBytes();
setState(() {
_imageFile = File(pickedFile.path);
_base64Image = base64Encode(bytes);
});
}
}
@override
void dispose() {
_nameController.dispose();
_shortNameController.dispose();
_modelNoController.dispose();
super.dispose();
}
Future<void> _submitForm() async {
final isEdit = widget.product != null;
// Check if the form is valid
if (!_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please correct the errors in the form'), backgroundColor: Colors.red),
);
return;
}
// Check if an image is selected for a new product
if (!isEdit && _imageFile == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select an image for the new product'), backgroundColor: Colors.red),
);
return;
}
setState(() {
_isLoading = true;
});
String imagePayload = '';
if (_base64Image != null) {
imagePayload = _base64Image!;
} else if (isEdit && widget.product?['imageProduct'] != null) {
imagePayload = widget.product!['imageProduct'];
}
final productData = {
'ProductName': _nameController.text,
'ProductShortName': _shortNameController.text,
'ManufacturerId': _selectedManufacturer['manufacturerId'],
'Category': _selectedCategory,
'ModelNo': _modelNoController.text,
'ImageProduct': imagePayload,
};
if (isEdit) {
productData['ProductId'] = widget.product!['productId'];
}
try {
if (isEdit) {
await _productService.updateProduct(productData);
} else {
await _productService.addProduct(productData);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Product ${isEdit ? 'updated' : 'added'} successfully'),
backgroundColor: Colors.green),
);
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save product: $e'), backgroundColor: Colors.red),
);
}
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.product != null;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: Text(isEdit ? 'Edit Product' : 'Add Product'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLabel('Product Name'),
const SizedBox(height: 8),
_buildTextField(
controller: _nameController,
hintText: 'Enter product name',
validator: (value) => (value == null || value.isEmpty) ? 'Product name is required' : null,
),
const SizedBox(height: 20),
_buildLabel('Product Short Name'),
const SizedBox(height: 8),
_buildTextField(
controller: _shortNameController,
hintText: 'Enter a short name',
helperText: '*Product short name limited to 13 characters',
validator: (value) => (value == null || value.isEmpty) ? 'Short name is required' : null,
),
const SizedBox(height: 20),
_buildLabel('Model No'),
const SizedBox(height: 8),
_buildTextField(
controller: _modelNoController,
hintText: 'Enter model number',
validator: (value) => (value == null || value.isEmpty) ? 'Model number is required' : null,
),
const SizedBox(height: 20),
_buildLabel('Manufacturer'),
const SizedBox(height: 8),
_buildDropdown(_manufacturers, _selectedManufacturer, 'Select Manufacturer', (val) {
setState(() => _selectedManufacturer = val);
}, (item) => item['manufacturerName']),
const SizedBox(height: 20),
_buildLabel('Category'),
const SizedBox(height: 8),
_buildDropdown(_categories, _selectedCategory, 'Select Category', (val) {
setState(() => _selectedCategory = val);
}, (item) => item.toString()),
const SizedBox(height: 20),
_buildLabel('Image'),
const SizedBox(height: 8),
_buildImagePicker(),
const SizedBox(height: 40),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _submitForm,
icon: _isLoading ? Container() : const Icon(Icons.save, color: Colors.white),
label: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
isEdit ? 'Update' : 'Submit',
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade300,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
),
),
],
),
),
),
);
}
Widget _buildImagePicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 180,
width: double.infinity,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Center(
child: _buildImagePreview(),
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _pickImage,
icon: const Icon(Icons.image_outlined),
label: const Text('Select Image'),
),
],
);
}
Widget _buildImagePreview() {
if (_imageFile != null) {
return Image.file(_imageFile!, fit: BoxFit.cover, width: double.infinity);
} else if (widget.product?['imageProduct'] != null && widget.product!['imageProduct'].isNotEmpty) {
const String baseUrl = 'https://dev9.pstw.com.my';
final String fullImageUrl = baseUrl + widget.product!['imageProduct'];
return Image.network(
fullImageUrl,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.broken_image_outlined, color: Colors.grey, size: 48),
SizedBox(height: 8),
Text('Could not load image', style: TextStyle(color: Colors.grey)),
],
);
},
);
} else {
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt_outlined, color: Colors.grey, size: 48),
SizedBox(height: 8),
Text('No image selected', style: TextStyle(color: Colors.grey)),
],
);
}
}
Widget _buildLabel(String text) {
return Text(text, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500));
}
Widget _buildTextField({
required TextEditingController controller,
String? hintText,
TextInputType? keyboardType,
String? helperText,
List<TextInputFormatter>? inputFormatters,
String? Function(String?)? validator,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
validator: validator,
decoration: InputDecoration(
labelText: hintText,
labelStyle: TextStyle(color: Colors.grey.shade600),
helperText: helperText,
helperStyle: const TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
border: const OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.purple.shade200, width: 2),
),
),
);
}
Widget _buildDropdown(
List<dynamic> items,
dynamic selectedItem,
String hint,
void Function(dynamic) onChanged,
String Function(dynamic) displayField,
) {
return DropdownButtonFormField<dynamic>(
value: selectedItem,
hint: Text(hint, style: TextStyle(color: Colors.grey.shade600)),
isExpanded: true,
items: items.map<DropdownMenuItem<dynamic>>((item) {
return DropdownMenuItem<dynamic>(
value: item,
child: Text(displayField(item)),
);
}).toList(),
onChanged: (val) => onChanged(val),
validator: (value) => value == null ? 'This field is required' : null,
decoration: InputDecoration(
border: const OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.purple.shade200, width: 2),
),
),
);
}
}

View File

@ -0,0 +1,718 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/services/product_request_service.dart'; // Import ProductRequestService
import 'package:inventory_system/services/session_manager.dart'; // Import SessionManager
import 'package:inventory_system/services/api_service.dart'; // Import ApiService
class InvMasterToInvMasterScreen extends StatefulWidget {
const InvMasterToInvMasterScreen({super.key});
@override
State<InvMasterToInvMasterScreen> createState() => _InvMasterToInvMasterScreenState();
}
class _InvMasterToInvMasterScreenState extends State<InvMasterToInvMasterScreen>
with SingleTickerProviderStateMixin {
final ProductRequestService _productRequestService = ProductRequestService(); // Service instance
List<Map<String, dynamic>> _requests = []; // To store fetched requests
bool _isLoading = true;
String? _errorMessage;
int? _currentUserId;
List<dynamic> _currentUserRoles = [];
int? _currentUserStore;
int _selectedIndex = 0;
late TabController _tabController;
// Dropdown state
String _selectedDropdownValue = 'Inventory Master → Inventory Master';
final List<String> _dropdownOptions = const [
'Technician → Inventory Master',
'Inventory Master → Inventory Master',
];
// Expanded state per card
int? _expandedIndex;
Key _tabViewKey = UniqueKey();
// Search controllers
final TextEditingController _allSearchController = TextEditingController();
final TextEditingController _outgoingSearchController = TextEditingController();
final TextEditingController _incomingSearchController = TextEditingController();
final TextEditingController _completeSearchController = TextEditingController();
// Filtered lists
late List<Map<String, dynamic>> _allFiltered;
late List<Map<String, dynamic>> _outgoingFiltered;
late List<Map<String, dynamic>> _incomingFiltered;
late List<Map<String, dynamic>> _completeFiltered;
@override
void initState() {
super.initState();
_fetchInitialData();
_allSearchController.addListener(_applyFilters);
_outgoingSearchController.addListener(_applyFilters);
_incomingSearchController.addListener(_applyFilters);
_completeSearchController.addListener(_applyFilters);
_tabController = TabController(length: 4, vsync: this);
_tabController.addListener(() {
if (!_tabController.indexIsChanging && _expandedIndex != null) {
setState(() {
_expandedIndex = null;
_tabViewKey = UniqueKey();
});
}
});
}
Future<void> _fetchInitialData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final allRequests = await _productRequestService.fetchProductRequests();
final currentUser = SessionManager.instance.currentUser;
if (currentUser == null) {
setState(() {
_errorMessage = 'Could not retrieve user session. Please log out and log back in.';
_isLoading = false;
});
return;
}
_currentUserId = currentUser['id'];
_currentUserRoles = currentUser['role'] as List<dynamic>? ?? [];
_currentUserStore = currentUser['store'];
List<Map<String, dynamic>> userRequests;
if (_currentUserRoles.contains("Super Admin") || _currentUserRoles.contains("System Admin")) {
userRequests = allRequests;
} else if (_currentUserRoles.contains("Inventory Master")) {
if (_currentUserStore == null) {
userRequests = allRequests;
} else {
userRequests = allRequests.where((r) {
final assignStore = r['assignStoreItem'];
final fromStore = r['fromStoreItem'];
return (assignStore != null && assignStore.toString() == _currentUserStore.toString()) ||
(fromStore != null && fromStore.toString() == _currentUserStore.toString());
}).toList();
}
} else {
userRequests = allRequests.where((r) {
return r['userId'].toString() == _currentUserId.toString();
}).toList();
}
setState(() {
_requests = userRequests;
_applyFilters();
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Failed to load data: $e';
_isLoading = false;
});
}
}
@override
void dispose() {
_allSearchController.removeListener(_applyFilters);
_outgoingSearchController.removeListener(_applyFilters);
_incomingSearchController.removeListener(_applyFilters);
_completeSearchController.removeListener(_applyFilters);
_allSearchController.dispose();
_outgoingSearchController.dispose();
_incomingSearchController.dispose();
_completeSearchController.dispose();
_tabController.dispose();
super.dispose();
}
bool _matchesQuery(Map<String, dynamic> m, String q) {
if (q.isEmpty) return true;
final keys = [
'productName', 'requestID', 'status', 'productCategory', 'userName',
'stationName', 'remarkMasterInv', 'remarkUser', 'requestDate','approvalDate'
];
for (final k in keys) {
final v = m[k]?.toString().toLowerCase();
if (v != null && v.contains(q)) return true;
}
return false;
}
bool _isMasterRequest(Map<String, dynamic> m) {
final val = m['assignStoreItem'];
return val != null && val != 0;
}
void _applyFilters() {
final allQuery = _allSearchController.text.toLowerCase();
final outgoingQuery = _outgoingSearchController.text.toLowerCase();
final incomingQuery = _incomingSearchController.text.toLowerCase();
final completeQuery = _completeSearchController.text.toLowerCase();
setState(() {
// Filter each list based on its own search controller.
_outgoingFiltered = _requests.where((item) {
return item['status'] == 'Requested' &&
item['userId'].toString() == _currentUserId.toString() &&
_isMasterRequest(item) &&
_matchesQuery(item, outgoingQuery);
}).toList();
_incomingFiltered = _requests.where((item) {
return item['status'] == 'Requested' &&
item['fromStoreItem'].toString() == _currentUserStore.toString() &&
_isMasterRequest(item) &&
_matchesQuery(item, incomingQuery);
}).toList();
_completeFiltered = _requests.where((item) {
return item['status'] != 'Requested' &&
_isMasterRequest(item) &&
_matchesQuery(item, completeQuery);
}).toList();
// For "All", combine the already filtered lists from the other tabs.
final combined = <Map<String, dynamic>>[
..._outgoingFiltered,
..._incomingFiltered,
..._completeFiltered,
];
// Remove duplicates using requestID
final uniqueIds = <dynamic>{};
final uniqueItems = combined.where((item) {
final isNew = uniqueIds.add(item['requestID']);
return isNew;
}).toList();
// Then, apply the "All" tab's own search filter.
_allFiltered = uniqueItems.where((item) => _matchesQuery(item, allQuery)).toList();
_expandedIndex = null;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: TitleBar(
title: 'Product Request',
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: PopupMenuButton<String>(
tooltip: 'Switch section',
onSelected: (newValue) {
if (newValue == 'Technician → Inventory Master') {
Navigator.pushReplacementNamed(context, '/product_request_t_to_im');
} else if (newValue == 'Inventory Master → Inventory Master') {
// stay
}
setState(() {
_selectedDropdownValue = newValue;
});
},
itemBuilder: (context) => _dropdownOptions
.map((v) => PopupMenuItem<String>(value: v, child: Text(v)))
.toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_selectedDropdownValue.startsWith('Technician') ? 'Tech → IM' : 'IM → IM',
style: const TextStyle(fontSize: 12, color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 4),
const Icon(Icons.keyboard_arrow_down, size: 18, color: Colors.black87),
],
),
),
),
),
),
],
),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.productRequest),
body: Column(
children: [
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
onTap: (_) {
setState(() {
_expandedIndex = null;
_tabViewKey = UniqueKey();
});
},
labelColor: Colors.purple.shade800,
unselectedLabelColor: Colors.grey.shade600,
indicatorColor: Colors.purple.shade800,
indicatorWeight: 3,
tabs: const [
Tab(text: 'All'),
Tab(text: 'Outgoing'),
Tab(text: 'Incoming'),
Tab(text: 'Complete'),
],
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Text(_errorMessage!))
: TabBarView(
controller: _tabController,
key: _tabViewKey,
children: [
_buildTabPage(
controller: _allSearchController,
items: _allFiltered,
tabKey: 'all'),
_buildTabPage(
controller: _outgoingSearchController,
items: _outgoingFiltered,
tabKey: 'outgoing'),
_buildTabPage(
controller: _incomingSearchController,
items: _incomingFiltered,
tabKey: 'incoming'),
_buildTabPage(
controller: _completeSearchController,
items: _completeFiltered,
tabKey: 'complete'),
],
),
),
],
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) {
Navigator.pop(context);
} else if (index == 1) {
if (mounted) {
Navigator.pushNamed(context, '/scan');
}
} else {
setState(() {
_selectedIndex = index;
});
}
},
),
);
}
Widget _buildTabPage({
required TextEditingController controller,
required List<Map<String, dynamic>> items,
required String tabKey,
}) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
ElevatedButton.icon(
onPressed: () {
Navigator.pushNamed(context, '/product_request_form').then((_) => _fetchInitialData());
},
icon: const Icon(Icons.add, size: 18),
label: const Text('Add Request'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade100,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: controller,
onChanged: (_) => _applyFilters(),
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.clear();
_applyFilters();
},
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) =>
_buildRequestCard(items[index], index, tabKey),
),
),
],
),
);
}
Widget _buildRequestCard(Map<String, dynamic> item, int index, String tabKey) {
final isExpanded = _expandedIndex == index;
final String imageUrl = item['productImage'] ?? '';
String status = _getStatus(item);
Color color = _getStatusColor(status);
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: imageUrl.isNotEmpty
? Image.network(
ApiService.baseUrl + imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.broken_image,
color: Colors.grey),
)
: const Icon(Icons.image_not_supported,
color: Colors.grey),
),
const SizedBox(height: 6),
Text(
item['productId']?.toString() ?? '-',
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
item['productName'] ?? '-',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color),
),
child: Text(
status,
style: TextStyle(
fontWeight: FontWeight.w700,
color: color,
),
),
),
],
),
const SizedBox(height: 4),
Text('ID: ${item['requestID'] ?? '-'}',
style: TextStyle(color: Colors.grey.shade600)),
const SizedBox(height: 2),
Text(item['requestDate'] ?? '-',
style: TextStyle(color: Colors.grey.shade600)),
],
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
Expanded(
child: Center(
child: InkWell(
onTap: () {
setState(() {
_expandedIndex = isExpanded ? null : index;
});
},
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
isExpanded ? 'Less Details' : 'More Details',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade600,
),
),
const SizedBox(width: 4),
Icon(
isExpanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
color: Colors.blue.shade600,
size: 20,
),
],
),
),
),
),
],
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isExpanded
? _buildDetailsSection(item)
: const SizedBox.shrink(),
),
],
),
),
);
}
String _getStatus(Map<String, dynamic> item) {
if (item['status'] == 'Requested') {
final bool isIncoming = item['fromStoreItem'] == _currentUserStore;
if (isIncoming) {
return 'Incoming';
}
final bool isOutgoing = item['userId'] == _currentUserId;
if (isOutgoing) {
return 'Outgoing';
}
}
return item['status'] ?? 'N/A';
}
Color _getStatusColor(String status) {
switch (status) {
case 'Outgoing':
return Colors.blue.shade400;
case 'Incoming':
return Colors.orange.shade400;
case 'Approved':
case 'Complete':
return Colors.green.shade500;
case 'Rejected':
return Colors.red.shade400;
case 'Requested':
return Colors.purple.shade400;
default:
return Colors.grey.shade400;
}
}
Widget _buildDetailsSection(Map<String, dynamic> item) {
final tiles = <Widget>[
_detailTile('Requested by User', item['userName'] ?? 'N/A'),
_detailTile('Requested by Station', item['stationName'] ?? 'N/A'),
_detailTile('Product Category', item['productCategory'] ?? 'N/A'),
_detailTile('Request Quantity', item['requestQuantity']?.toString() ?? 'N/A'),
_detailTile('Inventory Master Remark', item['remarkMasterInv'] ?? 'N/A'),
_detailTile('User Remark', item['remarkUser'] ?? 'N/A'),
_buildDocumentTile(item['document']),
_detailTile('Request Date', item['requestDate'] ?? 'N/A'),
if (item['approvalDate'] != null)
_detailTile('Approval Date', item['approvalDate']),
if (item['rejectedDate'] != null)
_detailTile('Rejected Date', item['rejectedDate'])
];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: LayoutBuilder(
builder: (context, constraints) {
const spacing = 16.0;
final tileWidth = (constraints.maxWidth - (spacing * 2)) / 3; // 3 columns
return Wrap(
spacing: spacing,
runSpacing: 12,
children: tiles.map((w) => SizedBox(width: tileWidth, child: w)).toList(),
);
},
),
);
}
Widget _detailTile(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
softWrap: true,
),
],
);
}
void _showDocument(String docPath) {
showDialog(
context: context,
builder: (ctx) => Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppBar(
title: const Text('Document'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(ctx),
),
elevation: 0,
backgroundColor: Colors.transparent,
foregroundColor: Colors.black,
),
InteractiveViewer(
child: Image.network(
ApiService.baseUrl + docPath,
fit: BoxFit.contain,
errorBuilder: (ctx, err, stack) => const Padding(
padding: EdgeInsets.all(20.0),
child: Column(
children: [
Icon(Icons.broken_image, size: 50, color: Colors.grey),
Text('Failed to load image'),
],
),
),
),
),
],
),
),
);
}
Widget _buildDocumentTile(String? docPath) {
final hasDoc = docPath != null && docPath.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Document/Picture',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
hasDoc
? InkWell(
onTap: () => _showDocument(docPath),
child: Text(
'Show',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade700,
decoration: TextDecoration.underline,
),
),
)
: const Text(
'No Document',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
);
}
}

View File

@ -0,0 +1,388 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:inventory_system/services/product_service.dart';
import 'package:inventory_system/services/store_service.dart';
import 'package:inventory_system/services/product_request_service.dart';
import 'package:inventory_system/services/session_manager.dart';
import 'package:inventory_system/services/api_service.dart';
import 'package:intl/intl.dart';
class ProductRequestFormScreen extends StatefulWidget {
const ProductRequestFormScreen({super.key});
@override
State<ProductRequestFormScreen> createState() => _ProductRequestFormScreenState();
}
class _ProductRequestFormScreenState extends State<ProductRequestFormScreen> {
final _formKey = GlobalKey<FormState>();
// Services
final _productService = ProductService();
final _storeService = StoreService();
final _requestService = ProductRequestService();
bool _isLoading = false;
bool _isSubmitting = false;
// Data Lists
List<dynamic> _products = [];
List<Map<String, dynamic>> _allStores = [];
List<Map<String, dynamic>> _userStores = [];
List<Map<String, dynamic>> _requestFromStores = [];
// Selections & Controllers
dynamic _selectedProduct;
int? _fromStoreId;
int? _toStoreId;
final _quantityController = TextEditingController();
final _remarkController = TextEditingController();
final _categoryController = TextEditingController();
// File placeholder
String? _documentPath;
@override
void initState() {
super.initState();
_fetchInitialData();
}
@override
void dispose() {
_quantityController.dispose();
_remarkController.dispose();
_categoryController.dispose();
super.dispose();
}
Future<void> _fetchInitialData() async {
setState(() => _isLoading = true);
try {
final currentUser = SessionManager.instance.currentUser;
final userId = currentUser?['id'];
if (userId == null) throw Exception("User not logged in");
final products = await _productService.fetchProducts();
final allStores = await _storeService.fetchStores();
final userStores = await _storeService.fetchSpecificUserStores(userId);
if (!mounted) return;
setState(() {
_products = products;
_allStores = allStores;
_userStores = userStores;
// Filter 'Request From' stores: Exclude stores the user manages
final userStoreIds = userStores.map((s) => s['id']).toSet();
_requestFromStores = allStores.where((s) => !userStoreIds.contains(s['id'])).toList();
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading data: $e'), backgroundColor: Colors.red),
);
setState(() => _isLoading = false);
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedProduct == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a product'), backgroundColor: Colors.red),
);
return;
}
setState(() => _isSubmitting = true);
try {
final currentUser = SessionManager.instance.currentUser;
final userId = currentUser?['id'];
// Construct payload based on web app logic
final requestData = {
"ProductId": _selectedProduct['productId'],
"StationId": null, // As per web app observation
"UserId": userId,
"ProductCategory": _selectedProduct['category'],
"RequestQuantity": int.parse(_quantityController.text),
"remarkUser": _remarkController.text,
"remarkMasterInv": "",
"status": "Requested",
"requestDate": DateTime.now().toIso8601String(), // Or use server time
"approvalDate": null,
"fromStoreItem": _fromStoreId,
"assignStoreItem": _toStoreId,
"Document": _documentPath // Placeholder for now
};
await _requestService.addRequestMaster(requestData);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Request submitted successfully'), backgroundColor: Colors.green),
);
Navigator.pop(context, true); // Return true to refresh list
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to submit: $e'), backgroundColor: Colors.red),
);
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black87),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'New Product Request',
style: TextStyle(color: Colors.black87, fontSize: 18, fontWeight: FontWeight.w600),
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SafeArea(
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLabel('Product'),
const SizedBox(height: 8),
_buildProductDropdown(),
const SizedBox(height: 16),
_buildLabel('Product Image'),
const SizedBox(height: 8),
_buildProductImage(),
const SizedBox(height: 16),
_buildLabel('Category'),
const SizedBox(height: 8),
_buildTextField(_categoryController, 'Product Category', readOnly: true),
const SizedBox(height: 16),
_buildLabel('Quantity'),
const SizedBox(height: 8),
_buildTextField(_quantityController, 'Enter quantity', keyboardType: TextInputType.number, isRequired: true),
const SizedBox(height: 16),
_buildLabel('Request From Store'),
const SizedBox(height: 8),
_buildStoreDropdown(
_requestFromStores,
_fromStoreId,
'Select Source Store',
(val) => setState(() => _fromStoreId = val)
),
const SizedBox(height: 16),
_buildLabel('Send To Store'),
const SizedBox(height: 8),
_buildStoreDropdown(
_userStores,
_toStoreId,
'Select Your Store',
(val) => setState(() => _toStoreId = val)
),
if (_userStores.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 4.0),
child: Text('No stores assigned to you.', style: TextStyle(color: Colors.red, fontSize: 12)),
),
const SizedBox(height: 16),
_buildLabel('Remark'),
const SizedBox(height: 8),
_buildTextField(_remarkController, 'Optional remarks'),
const SizedBox(height: 16),
_buildLabel('Document / Picture'),
const SizedBox(height: 8),
_buildFilePickerPlaceholder(),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade300,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
icon: _isSubmitting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.save),
label: Text(_isSubmitting ? 'Submitting...' : 'Submit Request', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
),
],
),
),
),
),
);
}
Widget _buildLabel(String text) {
return Text(text, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Colors.black87));
}
Widget _buildTextField(TextEditingController controller, String hint, {bool readOnly = false, TextInputType? keyboardType, bool isRequired = false}) {
return TextFormField(
controller: controller,
readOnly: readOnly,
keyboardType: keyboardType,
decoration: InputDecoration(
hintText: hint,
filled: true,
fillColor: readOnly ? Colors.grey.shade100 : Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
validator: isRequired ? (val) => (val == null || val.isEmpty) ? 'Required' : null : null,
);
}
Widget _buildProductDropdown() {
return DropdownButtonFormField<dynamic>(
value: _selectedProduct,
isExpanded: true,
hint: const Text('Select Product'),
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
items: _products.map((p) {
final name = p['productName'] ?? 'Unknown';
final model = p['modelNo'] ?? '';
return DropdownMenuItem(
value: p,
child: Text('$name ($model)', overflow: TextOverflow.ellipsis),
);
}).toList(),
onChanged: (val) {
setState(() {
_selectedProduct = val;
_categoryController.text = val?['category'] ?? '';
});
},
validator: (val) => val == null ? 'Required' : null,
);
}
Widget _buildStoreDropdown(List<Map<String, dynamic>> stores, int? currentValue, String hint, Function(int?) onChanged) {
return DropdownButtonFormField<int>(
value: currentValue,
isExpanded: true,
hint: Text(hint),
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
items: stores.map((s) {
return DropdownMenuItem<int>(
value: s['id'],
child: Text(s['storeName'] ?? 'Unknown Store', overflow: TextOverflow.ellipsis),
);
}).toList(),
onChanged: onChanged,
validator: (val) => val == null ? 'Required' : null,
);
}
Widget _buildProductImage() {
final imageUrl = _selectedProduct?['imageProduct'];
return Container(
height: 150,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: imageUrl != null && imageUrl.toString().isNotEmpty
? Image.network(
ApiService.baseUrl + imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.broken_image, color: Colors.grey, size: 40),
SizedBox(height: 8),
Text('Image not found', style: TextStyle(color: Colors.grey)),
],
),
)
: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image, color: Colors.grey, size: 40),
SizedBox(height: 8),
Text('Select a product to view image', style: TextStyle(color: Colors.grey)),
],
),
);
}
Widget _buildFilePickerPlaceholder() {
return InkWell(
onTap: () {
// Placeholder for file picker logic
setState(() {
_documentPath = "demo_document.pdf"; // Simulate selection
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("File selected (Demo)")));
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.attach_file, color: Colors.grey),
const SizedBox(width: 12),
Expanded(
child: Text(
_documentPath ?? 'Choose File...',
style: TextStyle(color: _documentPath != null ? Colors.black87 : Colors.grey.shade600),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,752 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/services/product_request_service.dart';
import 'package:inventory_system/services/session_manager.dart';
import 'package:inventory_system/services/api_service.dart';
class TechnicianToInvMasterScreen extends StatefulWidget {
const TechnicianToInvMasterScreen({super.key});
@override
State<TechnicianToInvMasterScreen> createState() => _TechnicianToInvMasterScreenState();
}
class _TechnicianToInvMasterScreenState extends State<TechnicianToInvMasterScreen>
with SingleTickerProviderStateMixin {
final ProductRequestService _productRequestService = ProductRequestService();
List<Map<String, dynamic>> _requests = [];
bool _isLoading = true;
String? _errorMessage;
int? _currentUserId;
List<dynamic> _currentUserRoles = [];
int _selectedIndex = 0;
late TabController _tabController;
String _selectedDropdownValue = 'Technician → Inventory Master';
final List<String> _dropdownOptions = const [
'Technician → Inventory Master',
'Inventory Master → Inventory Master',
];
int? _expandedIndex;
Key _tabViewKey = UniqueKey();
final TextEditingController _allSearchController = TextEditingController();
final TextEditingController _pendingSearchController = TextEditingController();
final TextEditingController _completeSearchController = TextEditingController();
final TextEditingController _rejectedSearchController = TextEditingController();
final TextEditingController _remarkController = TextEditingController();
late List<Map<String, dynamic>> _allFiltered;
late List<Map<String, dynamic>> _pendingFiltered;
late List<Map<String, dynamic>> _completeFiltered;
late List<Map<String, dynamic>> _rejectedFiltered;
@override
void initState() {
super.initState();
_fetchInitialData();
_allSearchController.addListener(_applyFilters);
_pendingSearchController.addListener(_applyFilters);
_completeSearchController.addListener(_applyFilters);
_rejectedSearchController.addListener(_applyFilters);
_tabController = TabController(length: 4, vsync: this);
_tabController.addListener(() {
if (!_tabController.indexIsChanging && _expandedIndex != null) {
setState(() {
_expandedIndex = null;
_tabViewKey = UniqueKey();
});
}
});
}
Future<void> _fetchInitialData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final allRequests = await _productRequestService.fetchProductRequests();
final currentUser = SessionManager.instance.currentUser;
if (currentUser == null) {
setState(() {
_errorMessage = 'Could not retrieve user session. Please log out and log back in.';
_isLoading = false;
});
return;
}
final currentUserId = currentUser['id'];
final currentUserRoles = currentUser['role'] as List<dynamic>? ?? [];
final currentUserStore = currentUser['store'];
List<Map<String, dynamic>> userRequests;
if (currentUserRoles.contains("Super Admin") || currentUserRoles.contains("System Admin")) {
userRequests = allRequests;
} else if (currentUserRoles.contains("Inventory Master")) {
userRequests = allRequests;
} else {
userRequests = allRequests.where((r) {
return r['userId'].toString() == currentUserId.toString();
}).toList();
}
setState(() {
_requests = userRequests;
_applyFilters();
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Failed to load data: $e';
_isLoading = false;
});
}
}
@override
void dispose() {
_allSearchController.removeListener(_applyFilters);
_pendingSearchController.removeListener(_applyFilters);
_completeSearchController.removeListener(_applyFilters);
_rejectedSearchController.removeListener(_applyFilters);
_allSearchController.dispose();
_pendingSearchController.dispose();
_completeSearchController.dispose();
_rejectedSearchController.dispose();
_remarkController.dispose();
_tabController.dispose();
super.dispose();
}
bool _isTechRequest(Map<String, dynamic> m) {
final val = m['assignStoreItem'];
return val == null || val == 0;
}
void _applyFilters() {
final allQuery = _allSearchController.text.toLowerCase();
final pendingQuery = _pendingSearchController.text.toLowerCase();
final completeQuery = _completeSearchController.text.toLowerCase();
final rejectedQuery = _rejectedSearchController.text.toLowerCase();
setState(() {
_allFiltered = _requests.where((m) => _isTechRequest(m) && _matchesQuery(m, allQuery)).toList();
_pendingFiltered = _requests
.where((m) => m['status'] == 'Requested' && _isTechRequest(m) && _matchesQuery(m, pendingQuery))
.toList();
_completeFiltered = _requests
.where((m) => m['status'] == 'Approved' && _isTechRequest(m) && _matchesQuery(m, completeQuery))
.toList();
_rejectedFiltered = _requests
.where((m) => m['status'] == 'Rejected' && _isTechRequest(m) && _matchesQuery(m, rejectedQuery))
.toList();
_expandedIndex = null;
});
}
bool _matchesQuery(Map<String, dynamic> m, String q) {
if (q.isEmpty) return true;
final keys = [
'productName', 'requestID', 'status', 'productCategory', 'userName',
'stationName', 'remarkMasterInv', 'remarkUser', 'requestDate','approvalDate'
];
for (final k in keys) {
final v = m[k]?.toString().toLowerCase();
if (v != null && v.contains(q)) return true;
}
return false;
}
void _applyAllFilter() {
final q = _allSearchController.text.toLowerCase();
setState(() {
_allFiltered = _requests.where((m) => _isTechRequest(m) && _matchesQuery(m, q)).toList();
_expandedIndex = null;
});
}
void _applyPendingFilter() {
final q = _pendingSearchController.text.toLowerCase();
setState(() {
_pendingFiltered = _requests
.where((m) => m['status'] == 'Requested' && _isTechRequest(m) && _matchesQuery(m, q))
.toList();
_expandedIndex = null;
});
}
void _applyCompleteFilter() {
final q = _completeSearchController.text.toLowerCase();
setState(() {
_completeFiltered = _requests
.where((m) => m['status'] == 'Approved' && _isTechRequest(m) && _matchesQuery(m, q))
.toList();
_expandedIndex = null;
});
}
void _applyRejectedFilter() {
final q = _rejectedSearchController.text.toLowerCase();
setState(() {
_rejectedFiltered = _requests
.where((m) => m['status'] == 'Rejected' && _isTechRequest(m) && _matchesQuery(m, q))
.toList();
_expandedIndex = null;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: TitleBar(
title: 'Product Request',
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: PopupMenuButton<String>(
tooltip: 'Switch section',
onSelected: (newValue) {
if (newValue == 'Inventory Master → Inventory Master') {
Navigator.pushReplacementNamed(context, '/product_request_im_to_im');
} else if (newValue == 'Technician → Inventory Master') {
// stay
}
setState(() {
_selectedDropdownValue = newValue;
});
},
itemBuilder: (context) => _dropdownOptions
.map((v) => PopupMenuItem<String>(value: v, child: Text(v)))
.toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_selectedDropdownValue.startsWith('Technician') ? 'Tech → IM' : 'IM → IM',
style: const TextStyle(fontSize: 12, color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 4),
const Icon(Icons.keyboard_arrow_down, size: 18, color: Colors.black87),
],
),
),
),
),
),
],
),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.productRequest),
body: Column(
children: [
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
onTap: (_) {
setState(() {
_expandedIndex = null; // collapse on tap change
_tabViewKey = UniqueKey();
});
},
labelColor: Colors.purple.shade800,
unselectedLabelColor: Colors.grey.shade600,
indicatorColor: Colors.purple.shade800,
indicatorWeight: 3,
tabs: const [
Tab(text: 'All'),
Tab(text: 'Pending'),
Tab(text: 'Complete'),
Tab(text: 'Rejected'),
],
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Text(_errorMessage!))
: TabBarView(
controller: _tabController,
key: _tabViewKey,
children: [
_buildTabPage(controller: _allSearchController, items: _allFiltered, tabKey: 'all'),
_buildTabPage(controller: _pendingSearchController, items: _pendingFiltered, tabKey: 'pending'),
_buildTabPage(controller: _completeSearchController, items: _completeFiltered, tabKey: 'complete'),
_buildTabPage(controller: _rejectedSearchController, items: _rejectedFiltered, tabKey: 'rejected'),
],
),
),
],
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) {
Navigator.pop(context);
} else if (index == 1) {
if (mounted) {
Navigator.pushNamed(context, '/scan');
}
} else {
setState(() {
_selectedIndex = index;
});
}
},
),
);
}
Widget _buildTabPage({
required TextEditingController controller,
required List<Map<String, dynamic>> items,
required String tabKey,
}) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: controller,
onChanged: (_) {
switch (tabKey) {
case 'all':
_applyAllFilter();
break;
case 'pending':
_applyPendingFilter();
break;
case 'complete':
_applyCompleteFilter();
break;
case 'rejected':
_applyRejectedFilter();
break;
}
},
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.clear();
switch (tabKey) {
case 'all':
_applyAllFilter();
break;
case 'pending':
_applyPendingFilter();
break;
case 'complete':
_applyCompleteFilter();
break;
case 'rejected':
_applyRejectedFilter();
break;
}
},
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => _buildRequestCard(items[index], index),
),
),
],
),
);
}
Widget _buildRequestCard(Map<String, dynamic> item, int index) {
final isExpanded = _expandedIndex == index;
final String imageUrl = item['productImage'] ?? '';
Color statusColor(String status) {
switch (status) {
case 'Requested':
return Colors.blue.shade800;
case 'Approved':
return Colors.green.shade700;
case 'Rejected':
return Colors.red.shade400;
default:
return Colors.grey.shade400;
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image + Product code under it
Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: imageUrl.isNotEmpty
? Image.network(
ApiService.baseUrl + imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => const Icon(Icons.broken_image, color: Colors.grey),
)
: const Icon(Icons.image_not_supported, color: Colors.grey),
),
const SizedBox(height: 6),
Text(
item['productId']?.toString() ?? '-',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
],
),
const SizedBox(width: 16),
// Main Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
item['productName'] ?? '-',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
// Status badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: statusColor(item['status'] ?? '').withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: statusColor(item['status'] ?? '')),
),
child: Text(
item['status'] ?? '-',
style: TextStyle(
fontWeight: FontWeight.w700,
color: statusColor(item['status'] ?? ''),
),
),
),
],
),
const SizedBox(height: 4),
Text('ID: ${item['requestID'] ?? '-'}', style: TextStyle(color: Colors.grey.shade600)),
const SizedBox(height: 2),
Text(item['requestDate'] ?? '-', style: TextStyle(color: Colors.grey.shade600)),
],
),
),
],
),
),
// Actions row: More Details + Approve/Reject
Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
// Center the More Details when NOT pending; keep left-aligned for Pending so approve/reject stay visible
Builder(builder: (context) {
final isPending = (item['status'] ?? '') == 'Requested';
final moreDetailsWidget = InkWell(
onTap: () {
setState(() {
_expandedIndex = isExpanded ? null : index; // only one at a time
});
},
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
isExpanded ? 'Less Details' : 'More Details',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade600,
),
),
const SizedBox(width: 4),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
color: Colors.blue.shade600,
size: 20,
),
],
),
);
return Expanded(
child: isPending
? Align(alignment: Alignment.centerLeft, child: moreDetailsWidget)
: Center(child: moreDetailsWidget),
);
}),
// Only add spacing and buttons when Pending
if ((item['status'] ?? '') == 'Requested') ...[
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => _showRemarkDialog('Approve', item['requestID']),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade200,
foregroundColor: Colors.black,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('Approve'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => _showRemarkDialog('Reject', item['requestID']),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade200,
foregroundColor: Colors.black,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('Reject'),
),
],
],
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isExpanded ? _buildDetailsSection(item) : const SizedBox.shrink(),
),
],
),
),
);
}
Widget _buildDetailsSection(Map<String, dynamic> item) {
final tiles = <Widget>[
_detailTile('Requested by User', item['userName'] ?? 'N/A'),
_detailTile('Requested by Station', item['stationName'] ?? 'N/A'),
_detailTile('Product Category', item['productCategory'] ?? 'N/A'),
_detailTile('Request Quantity', item['requestQuantity']?.toString() ?? 'N/A'),
_detailTile('Inventory Master Remark', item['remarkMasterInv'] ?? 'N/A'),
_detailTile('User Remark', item['remarkUser'] ?? 'N/A'),
_buildDocumentTile(item['document']),
_detailTile('Request Date', item['requestDate'] ?? 'N/A'),
if (item['approvalDate'] != null)
_detailTile('Approval Date', item['approvalDate']),
if (item['rejectedDate'] != null)
_detailTile('Rejected Date', item['rejectedDate'])
];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: LayoutBuilder(
builder: (context, constraints) {
const spacing = 16.0;
final tileWidth = (constraints.maxWidth - (spacing * 2)) / 3; // 3 columns
return Wrap(
spacing: spacing,
runSpacing: 12,
children: tiles.map((w) => SizedBox(width: tileWidth, child: w)).toList(),
);
},
),
);
}
void _showRemarkDialog(String action, int requestId) {
_remarkController.clear();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('$action Request'),
content: TextField(
controller: _remarkController,
decoration: const InputDecoration(hintText: 'Enter remark (optional)'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
final remark = _remarkController.text;
Navigator.pop(context);
try {
if (action == 'Approve') {
await _productRequestService.approveRequest(requestId, remark);
} else {
await _productRequestService.rejectRequest(requestId, remark);
}
_fetchInitialData();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to $action request: $e')),
);
}
},
child: Text(action),
),
],
),
);
}
Widget _detailTile(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
softWrap: true,
),
],
);
}
void _showDocument(String docPath) {
showDialog(
context: context,
builder: (ctx) => Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppBar(
title: const Text('Document'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(ctx),
),
elevation: 0,
backgroundColor: Colors.transparent,
foregroundColor: Colors.black,
),
InteractiveViewer(
child: Image.network(
ApiService.baseUrl + docPath,
fit: BoxFit.contain,
errorBuilder: (ctx, err, stack) => const Padding(
padding: EdgeInsets.all(20.0),
child: Column(
children: [
Icon(Icons.broken_image, size: 50, color: Colors.grey),
Text('Failed to load image'),
],
),
),
),
),
],
),
),
);
}
Widget _buildDocumentTile(String? docPath) {
final hasDoc = docPath != null && docPath.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Document/Picture',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
hasDoc
? InkWell(
onTap: () => _showDocument(docPath),
child: Text(
'Show',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade700,
decoration: TextDecoration.underline,
),
),
)
: const Text(
'No Document',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
);
}
}

View File

@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:inventory_system/screens/admin/scan/scan_result.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
class ScanScreen extends StatefulWidget {
const ScanScreen({super.key});
@override
State<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
int _selectedIndex = 1;
bool _isProcessing = false;
void _onDetect(BarcodeCapture capture) async {
if (_isProcessing) return;
final codes = capture.barcodes;
if (codes.isEmpty) return;
final value = codes.first.rawValue;
if (value == null || value.isEmpty) return;
_isProcessing = true;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ScanResultScreen(scannedCode: value.trim()),
),
);
// brief debounce to avoid flood
await Future.delayed(const Duration(milliseconds: 500));
_isProcessing = false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: const TitleBar(title: 'Scan Item'),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.item),
body: Stack(
fit: StackFit.expand,
children: [
// 1. Add Padding around the camera view
Padding(
padding: const EdgeInsets.all(38.38), // Adjust the padding as you need
child: ClipRRect(
// 2. (Optional) Add rounded corners to the camera view
borderRadius: BorderRadius.circular(24.0),
child: MobileScanner(
fit: BoxFit.cover, // Use BoxFit.cover for a better aspect ratio
onDetect: _onDetect,
),
),
),
_ScannerOverlay(),
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Text(
'Place the code inside the frame',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
blurRadius: 10.0,
color: Colors.black.withOpacity(0.5),
offset: const Offset(0, 0),
),
],
),
),
),
],
), bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
setState(() => _selectedIndex = index);
if (index == 0) {
Navigator.pop(context);
}
},
),
);
}
}
class _ScannerOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: CustomPaint(
painter: _OverlayPainter(),
size: Size.infinite,
),
);
}
}
class _OverlayPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 3;
final double padding = 48;
final rect = RRect.fromRectAndRadius(
Rect.fromLTWH(padding, padding, size.width - padding * 2, size.height - padding * 2),
const Radius.circular(12),
);
final bg = Paint()..color = Colors.black.withOpacity(0.5);
final clearPaint = Paint()..blendMode = BlendMode.clear;
final path = Path()..addRRect(rect);
canvas.saveLayer(Offset.zero & size, Paint());
canvas.drawRect(Offset.zero & size, bg);
canvas.drawPath(path, clearPaint);
canvas.restore();
canvas.drawRRect(rect, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:inventory_system/services/station_service.dart';
import 'package:inventory_system/screens/admin/station/station_form.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/title_bar.dart';
class StationScreen extends StatefulWidget {
const StationScreen({super.key});
@override
State<StationScreen> createState() => _StationScreenState();
}
class _StationScreenState extends State<StationScreen> {
int _selectedIndex = 0;
final StationService _stationService = StationService();
late Future<List<dynamic>> _stationsFuture;
List<dynamic> _allStations = [];
List<dynamic> _filteredStations = [];
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_stationsFuture = _fetchStations();
_searchController.addListener(_onSearchChanged);
}
Future<List<dynamic>> _fetchStations() async {
try {
final stations = await _stationService.fetchStations();
stations.sort((a, b) {
final nameA = a['stationName'] as String? ?? '';
final nameB = b['stationName'] as String? ?? '';
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
});
if (mounted) {
setState(() {
_allStations = stations;
_filteredStations = stations;
});
}
return stations;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load stations: $e'), backgroundColor: Colors.red),
);
}
rethrow;
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredStations = _allStations.where((station) {
final name = station['stationName'].toString().toLowerCase();
final department = station['departmentName'].toString().toLowerCase();
return name.contains(query) || department.contains(query);
}).toList();
});
}
void _handleDelete(int stationId) async {
try {
await _stationService.deleteStation(stationId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Station deleted successfully'),
backgroundColor: Colors.green),
);
_fetchStations();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to delete station: $e'),
backgroundColor: Colors.red),
);
}
}
}
void _confirmDelete(int stationId, String stationName) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Station'),
content: Text('Are you sure you want to delete $stationName?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel')),
TextButton(
onPressed: () {
Navigator.pop(context);
_handleDelete(stationId);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _navigateAndRefresh() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const StationFormScreen()),
).then((_) => _fetchStations());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: const TitleBar(title: 'Station'),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.station),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
ElevatedButton.icon(
onPressed: _navigateAndRefresh,
icon: const Icon(Icons.add, size: 18),
label: const Text('Add Station'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade100,
foregroundColor: Colors.black87,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none),
),
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchStations,
child: FutureBuilder<List<dynamic>>(
future: _stationsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting &&
_allStations.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && _allStations.isEmpty) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (_filteredStations.isEmpty) {
return const Center(child: Text('No stations found.'));
}
return ListView.builder(
itemCount: _filteredStations.length,
itemBuilder: (context, index) {
final station = _filteredStations[index];
return Slidable(
key: Key(station['stationId'].toString()),
endActionPane: ActionPane(
motion: const StretchMotion(),
children: [
SlidableAction(
onPressed: (context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
StationFormScreen(station: station)),
).then((_) => _fetchStations());
},
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
icon: Icons.edit,
label: 'Edit',
),
SlidableAction(
onPressed: (context) => _confirmDelete(
station['stationId'],
station['stationName'],
),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete,
label: 'Delete',
),
],
),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
color: Colors.white,
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade300),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16),
title: Text(
station['stationName'] ?? 'No Name',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
subtitle: Text(
station['departmentName'] ?? 'No Department'),
),
),
);
},
);
},
),
),
),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) {
Navigator.pop(context);
} else {
setState(() {
_selectedIndex = index;
});
}
},
),
);
}
}

View File

@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/services/station_service.dart';
import 'package:inventory_system/services/user_service.dart';
class StationFormScreen extends StatefulWidget {
final Map<String, dynamic>? station;
const StationFormScreen({super.key, this.station});
@override
State<StationFormScreen> createState() => _StationFormScreenState();
}
class _StationFormScreenState extends State<StationFormScreen> {
final _formKey = GlobalKey<FormState>();
final _stationService = StationService();
final _userService = UserService();
bool _isLoading = false;
late TextEditingController _nameController;
late TextEditingController _departmentController; // For display
List<dynamic> _users = [];
dynamic _selectedUser;
@override
void initState() {
super.initState();
// Use camelCase for data from StationList
_nameController = TextEditingController(text: widget.station?['stationName']?.toString() ?? '');
_departmentController = TextEditingController(text: widget.station?['departmentName']?.toString() ?? '');
_fetchUsers();
}
Future<void> _fetchUsers() async {
try {
final users = await _userService.fetchUsers();
if (!mounted) return;
setState(() {
_users = users;
if (widget.station != null) {
// Use camelCase for StationList data, but PascalCase for user data
final picId = widget.station!['stationPicID'];
_selectedUser = _users.firstWhere((user) => user['id'] == picId, orElse: () => null);
}
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to load users: $e'),
backgroundColor: Colors.red),
);
}
}
}
@override
void dispose() {
_nameController.dispose();
_departmentController.dispose();
super.dispose();
}
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedUser == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a user'), backgroundColor: Colors.red),
);
return;
}
setState(() {
_isLoading = true;
});
final isEdit = widget.station != null;
// Send PascalCase keys to the API
final stationData = {
'StationName': _nameController.text,
'StationPicID': _selectedUser['id'],
'DepartmentId': _selectedUser['department']['departmentId'],
};
if (isEdit) {
stationData['StationId'] = widget.station!['stationId'];
}
try {
if (isEdit) {
await _stationService.updateStation(stationData);
} else {
await _stationService.addStation(stationData);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Station ${isEdit ? 'updated' : 'added'} successfully'),
backgroundColor: Colors.green),
);
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to save station: $e'),
backgroundColor: Colors.red),
);
}
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.station != null;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black87),
onPressed: () => Navigator.pop(context),
),
title: Text(
isEdit ? 'Edit Station' : 'Add Station',
style: const TextStyle(
color: Colors.black87, fontSize: 18, fontWeight: FontWeight.w600),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLabel('Station Name'),
const SizedBox(height: 8),
_buildTextField(
controller: _nameController,
hintText: 'Enter station name',
validator: (value) =>
(value == null || value.isEmpty) ? 'Station name is required' : null,
),
const SizedBox(height: 20),
_buildLabel('Station User PIC'),
const SizedBox(height: 8),
_buildUserDropdown(),
const SizedBox(height: 20),
_buildLabel('Department'),
const SizedBox(height: 8),
_buildTextField(
controller: _departmentController,
hintText: '',
readOnly: true,
),
const SizedBox(height: 40),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _submitForm,
icon: _isLoading
? Container()
: const Icon(Icons.save, color: Colors.white),
label: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
isEdit ? 'Update' : 'Submit',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade300,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
),
),
],
),
),
),
);
}
Widget _buildUserDropdown() {
return DropdownButtonFormField<dynamic>(
value: _selectedUser,
hint: const Text('Select a user'),
isExpanded: true,
items: _users.map<DropdownMenuItem<dynamic>>((user) {
return DropdownMenuItem<dynamic>(
value: user,
// Use camelCase for user list data
child: Text(user['fullName'] ?? 'Unnamed User'),
);
}).toList(),
onChanged: (newValue) {
setState(() {
_selectedUser = newValue;
// Use camelCase for user list data
_departmentController.text = newValue?['department']?['departmentName'] ?? '';
});
},
validator: (value) => value == null ? 'Please select a user' : null,
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
Widget _buildLabel(String text) {
return Text(text,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w500, color: Colors.black87));
}
Widget _buildTextField({
required TextEditingController controller,
required String hintText,
bool readOnly = false,
String? Function(String?)? validator,
}) {
return TextFormField(
controller: controller,
readOnly: readOnly,
validator: validator,
decoration: InputDecoration(
hintText: hintText,
filled: true,
fillColor: readOnly ? Colors.grey.shade200 : Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
}

View File

@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:inventory_system/services/supplier_service.dart';
import 'package:inventory_system/screens/admin/supplier/supplier_form.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/title_bar.dart';
class SupplierScreen extends StatefulWidget {
const SupplierScreen({super.key});
@override
State<SupplierScreen> createState() => _SupplierScreenState();
}
class _SupplierScreenState extends State<SupplierScreen> {
int _selectedIndex = 0;
final SupplierService _supplierService = SupplierService();
late Future<List<dynamic>> _suppliersFuture;
List<dynamic> _allSuppliers = [];
List<dynamic> _filteredSuppliers = [];
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_suppliersFuture = _fetchSuppliers();
_searchController.addListener(_onSearchChanged);
}
Future<List<dynamic>> _fetchSuppliers() async {
try {
final suppliers = await _supplierService.fetchSuppliers();
// Sort suppliers alphabetically by company name
suppliers.sort((a, b) {
final nameA = a['supplierCompName'] as String? ?? '';
final nameB = b['supplierCompName'] as String? ?? '';
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
});
if (mounted) {
setState(() {
_allSuppliers = suppliers;
_filteredSuppliers = suppliers;
});
}
return suppliers;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load suppliers: $e')),
);
}
rethrow;
}
}
@override
void dispose() {
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredSuppliers = _allSuppliers.where((supplier) {
final name = supplier['supplierCompName'].toString().toLowerCase();
final address = supplier['supplierAddress'].toString().toLowerCase();
return name.contains(query) || address.contains(query);
}).toList();
});
}
void _handleDelete(int supplierId) async {
try {
await _supplierService.deleteSupplier(supplierId);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Supplier deleted successfully')),
);
_fetchSuppliers(); // Refresh the list
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete supplier: $e')),
);
}
}
}
void _confirmDelete(int supplierId, String supplierName) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Supplier'),
content: Text('Are you sure you want to delete $supplierName?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
_handleDelete(supplierId);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: const TitleBar(title: 'Supplier'),
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.supplier),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SupplierFormScreen(),
),
).then((_) => _fetchSuppliers());
},
icon: const Icon(Icons.add, size: 18),
label: const Text('Add Supplier'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade100,
foregroundColor: Colors.black87,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchSuppliers,
child: FutureBuilder<List<dynamic>>(
future: _suppliersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text(
'Error: ${snapshot.error}',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
);
}
if (_filteredSuppliers.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'No suppliers found',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
],
),
);
}
return ListView.builder(
itemCount: _filteredSuppliers.length,
itemBuilder: (context, index) {
final supplier = _filteredSuppliers[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Slidable(
key: Key(supplier['supplierId'].toString()),
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
onPressed: (context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
SupplierFormScreen(supplier: supplier),
),
).then((_) => _fetchSuppliers());
},
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
icon: Icons.edit,
label: 'Edit',
),
SlidableAction(
onPressed: (context) => _confirmDelete(
supplier['supplierId'],
supplier['supplierCompName'],
),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete,
label: 'Delete',
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(16)),
),
],
),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.grey.shade300, width: 1),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
supplier['supplierCompName'] ?? 'N/A',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
Text(
supplier['supplierPhoneNo'] ?? 'N/A',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
],
),
const SizedBox(height: 8),
Text(
supplier['supplierAddress'] ??
'No address provided',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
height: 1.4,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
supplier['supplierPIC'] ?? 'N/A',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
),
Text(
supplier['supplierEmail'] ??
'No email provided',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontStyle: FontStyle.italic),
),
],
),
],
),
),
),
);
},
);
},
),
),
),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) {
Navigator.pop(context);
} else {
setState(() {
_selectedIndex = index;
});
}
},
),
);
}
}

View File

@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/services/supplier_service.dart';
class SupplierFormScreen extends StatefulWidget {
// Changed to dynamic to match the data from the service
final Map<String, dynamic>? supplier;
const SupplierFormScreen({super.key, this.supplier});
@override
State<SupplierFormScreen> createState() => _SupplierFormScreenState();
}
class _SupplierFormScreenState extends State<SupplierFormScreen> {
final _formKey = GlobalKey<FormState>();
final _supplierService = SupplierService();
bool _isLoading = false;
late TextEditingController _nameController;
late TextEditingController _addressController;
late TextEditingController _picController;
late TextEditingController _emailController;
late TextEditingController _phoneController;
@override
void initState() {
super.initState();
// Initialize controllers with supplier data if in edit mode
_nameController = TextEditingController(text: widget.supplier?['supplierCompName']?.toString());
_addressController = TextEditingController(text: widget.supplier?['supplierAddress']?.toString());
_picController = TextEditingController(text: widget.supplier?['supplierPIC']?.toString());
_emailController = TextEditingController(text: widget.supplier?['supplierEmail']?.toString());
_phoneController = TextEditingController(text: widget.supplier?['supplierPhoneNo']?.toString());
}
@override
void dispose() {
_nameController.dispose();
_addressController.dispose();
_picController.dispose();
_emailController.dispose();
_phoneController.dispose();
super.dispose();
}
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
final isEdit = widget.supplier != null;
final supplierData = {
'SupplierId': isEdit ? widget.supplier!['supplierId'] : 0,
'SupplierCompName': _nameController.text,
'SupplierAddress': _addressController.text,
'SupplierPIC': _picController.text,
'SupplierEmail': _emailController.text,
'SupplierPhoneNo': _phoneController.text,
};
try {
if (isEdit) {
await _supplierService.updateSupplier(supplierData);
} else {
await _supplierService.addSupplier(supplierData);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Supplier ${isEdit ? 'updated' : 'added'} successfully'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, true); // Return true to indicate success
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save supplier: $e'), backgroundColor: Colors.red),
);
}
setState(() {
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
final isEdit = widget.supplier != null;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black87),
onPressed: () => Navigator.pop(context),
),
title: Text(
isEdit ? 'Edit Supplier' : 'Add Supplier',
style: const TextStyle(color: Colors.black87, fontSize: 18, fontWeight: FontWeight.w600),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLabel('Supplier Company Name'),
const SizedBox(height: 8),
_buildTextField(
controller: _nameController,
hintText: 'Enter company name',
validator: (value) => (value == null || value.isEmpty) ? 'Company name is required' : null,
),
const SizedBox(height: 20),
_buildLabel('Supplier Address'),
const SizedBox(height: 8),
_buildTextField(
controller: _addressController,
hintText: 'Enter address',
maxLines: 3,
validator: (value) => (value == null || value.isEmpty) ? 'Address is required' : null,
),
const SizedBox(height: 20),
_buildLabel('Supplier PIC'),
const SizedBox(height: 8),
_buildTextField(
controller: _picController,
hintText: 'Enter PIC name',
),
const SizedBox(height: 20),
_buildLabel('Supplier Email'),
const SizedBox(height: 8),
_buildTextField(
controller: _emailController,
hintText: 'Enter email',
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value != null && value.isNotEmpty && !value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 20),
_buildLabel('Supplier Phone Number'),
const SizedBox(height: 8),
_buildTextField(
controller: _phoneController,
hintText: 'Enter phone number',
keyboardType: TextInputType.phone,
),
const SizedBox(height: 40),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _submitForm,
icon: _isLoading ? Container() : const Icon(Icons.save, color: Colors.white),
label: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
isEdit ? 'Update' : 'Submit',
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade300,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
),
),
],
),
),
),
);
}
Widget _buildLabel(String text) {
return Text(text, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Colors.black87));
}
Widget _buildTextField({
required TextEditingController controller,
required String hintText,
int maxLines = 1,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
return TextFormField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.purple.shade200, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.red),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.red, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
);
}
}

View File

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/services/session_manager.dart';
class BottomNavBar extends StatelessWidget {
final int selectedIndex;
final Function(int) onItemTapped;
const BottomNavBar({
super.key,
required this.selectedIndex,
required this.onItemTapped,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: BottomNavigationBar(
currentIndex: selectedIndex,
onTap: (index) {
final currentUser = SessionManager.instance.currentUser;
final bool isAdmin = currentUser?['isAdmin'] ?? false;
final String homeRoute = isAdmin ? '/home' : '/user-home';
if (index == 0) {
final currentRoute = ModalRoute.of(context)?.settings.name;
if (currentRoute != homeRoute) {
Navigator.pushNamedAndRemoveUntil(context, homeRoute, (route) => false);
}
return;
}
if (index == 1) {
final currentRoute = ModalRoute.of(context)?.settings.name;
final scanRoute = isAdmin ? '/scan' : '/scan-user';
if (currentRoute != homeRoute) {
Navigator.pushNamed(context, homeRoute);
}
if (currentRoute != scanRoute) {
Navigator.pushNamed(context, scanRoute);
}
return;
}
onItemTapped(index);
},
backgroundColor: Colors.blue[800],
elevation: 0,
selectedItemColor: Colors.black87,
unselectedItemColor: Colors.white,
selectedFontSize: 11,
unselectedFontSize: 11,
type: BottomNavigationBarType.fixed,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_rounded, size: 26),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.qr_code_scanner_rounded, size: 26),
label: 'Scan item',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_rounded, size: 26),
label: 'Profile',
),
],
),
);
}
}

View File

@ -0,0 +1,395 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/services/api_service.dart'; // 1. Import ApiService
import 'package:inventory_system/services/auth_service.dart';
import 'package:inventory_system/screens/admin/home_screen/home_screen.dart';
import 'package:inventory_system/screens/user/home_screen/home_screen_user.dart';
import 'package:inventory_system/routes/slide_route.dart';
import 'package:inventory_system/services/session_manager.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _isLoading = false;
bool _rememberMe = false;
final AuthService _authService = AuthService();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleSignIn() async {
if (_formKey.currentState?.validate() ?? false) {
setState(() {
_isLoading = true;
});
final result = await _authService.signIn(
usernameOrEmail: _emailController.text.trim(),
password: _passwordController.text,
);
setState(() {
_isLoading = false;
});
if (!mounted) return;
if (result['success'] == true) {
SessionManager.instance.startSession(result['data']);
final bool isAdmin = result['data']?['isAdmin'] ?? false;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) =>
isAdmin
? const HomeScreen()
: const UserHomeScreen(),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'An unknown error occurred.'),
backgroundColor: Colors.redAccent,
),
);
}
}
}
// 2. ADD THIS METHOD to show the URL settings dialog
void _showUrlDialog(BuildContext context) {
final TextEditingController urlController = TextEditingController();
urlController.text = ApiService.baseUrl; // Set current URL
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text("Set Server URL"),
content: TextField(
controller: urlController,
decoration: const InputDecoration(
labelText: "Base URL",
hintText: "https://...",
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text("Cancel"),
),
ElevatedButton(
onPressed: () async {
if (urlController.text.isNotEmpty) {
// Save the new URL
await ApiService.setBaseUrl(urlController.text);
Navigator.pop(dialogContext); // Close the dialog
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("New URL Saved!"),
backgroundColor: Colors.green,
),
);
}
},
child: const Text("Save"),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack( // 3. Use a Stack to overlay the settings button
children: [
// This is your existing UI
Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF0D47A1),
Color(0xFF1976D2),
Color(0xFF42A5F5),
Color(0xFF90CAF9),
],
stops: [0.0, 0.3, 0.7, 1.0],
),
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
children: [
const SizedBox(height: 0),
Image.asset(
'assets/images/logo.png',
width: 200,
height: 200,
fit: BoxFit.contain,
),
const SizedBox(height: 0),
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: const Icon(
Icons.person_outline,
size: 50,
color: Colors.white,
),
),
const SizedBox(height: 24),
Text(
'Sign in to continue',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(1, 1),
blurRadius: 4,
color: Colors.black.withOpacity(0.3),
),
],
),
),
const SizedBox(height: 40),
// Login form
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Email or Username',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1976D2),
),
),
const SizedBox(height: 8),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
hintText: 'Enter your email',
prefixIcon: const Icon(Icons.email_outlined,
color: Color(0xFF1976D2)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF1976D2), width: 2),
),
filled: true,
fillColor: Colors.grey.shade50,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your username or email';
}
return null; // Return null if the input is valid
},
),
const SizedBox(height: 20),
const Text(
'Password',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1976D2),
),
),
const SizedBox(height: 8),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline,
color: Color(0xFF1976D2)),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Color(0xFF1976D2),
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF1976D2), width: 2),
),
filled: true,
fillColor: Colors.grey.shade50,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
Row(
children: [
Checkbox(
value: _rememberMe,
onChanged: (value) {
setState(() {
_rememberMe = value ?? false;
});
},
),
const Text(
'Remember me',
style: TextStyle(
fontSize: 14,
color: Colors.black,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleSignIn,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1976D2),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 3,
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Sign In',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 16),
Center(
child: TextButton(
onPressed: () {
// TODO: Implement forgot password
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Forgot password functionality to be implemented'),
),
);
},
child: const Text(
'Forgot password?',
style: TextStyle(
fontSize: 14,
color: Color(0xFF1976D2),
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
),
],
),
)),
),
),
// 4. ADD THE SETTINGS BUTTON on top of the UI
Positioned(
top: MediaQuery.of(context).padding.top + 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.settings, color: Colors.white, size: 28),
tooltip: 'Server Settings',
onPressed: () {
_showUrlDialog(context);
},
),
),
],
),
);
}
}

248
lib/screens/nav_bar.dart Normal file
View File

@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/services/session_manager.dart';
enum AppScreen {
dashboard,
supplier,
manufacturer,
product,
item,
station,
productRequest,
itemMovement,
settings,
productRequestUser,
itemMovementUser,
scan,
}
class NavBar extends StatelessWidget {
final bool isAdmin;
final AppScreen selectedScreen;
const NavBar({super.key, required this.isAdmin, required this.selectedScreen});
@override
Widget build(BuildContext context) {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue.shade700, Colors.blue.shade400],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Builder(
builder: (context) {
final currentUser = SessionManager.instance.currentUser;
final String displayName = currentUser?['fullName'] ?? 'Not Logged In';
final bool isSuperAdmin = currentUser?['isSuperAdmin'] ?? false;
final bool isInventoryMaster = currentUser?['isInventoryMaster'] ?? false;
String userRole = 'User';
if (isSuperAdmin) {
userRole = 'Administrator';
} else if (isInventoryMaster) {
userRole = 'Inventory Master';
}
final String departmentName = currentUser?['department']?['departmentName'] ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const CircleAvatar(
radius: 30,
backgroundColor: Colors.white,
child: Icon(Icons.person, size: 35, color: Colors.blue),
),
const SizedBox(height: 10),
Text(
displayName, // Use the dynamic display name
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
Text(
userRole, // Use the dynamic user role
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
if (departmentName.isNotEmpty)
Text(
departmentName,
style: const TextStyle(
color: Colors.white60,
fontSize: 12,
),
),
],
);
},
),
),
if (isAdmin)
..._buildAdminListTiles(context)
else
..._buildUserListTiles(context),
const Divider(),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),
selected: selectedScreen == AppScreen.settings,
onTap: () {},
),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: () {
SessionManager.instance.clearSession();
Navigator.pushReplacementNamed(context, '/login');
},
),
],
),
);
}
List<Widget> _buildAdminListTiles(BuildContext context) {
return [
ListTile(
leading: const Icon(Icons.dashboard),
title: const Text('Dashboard'),
selected: selectedScreen == AppScreen.dashboard,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.dashboard) {
Navigator.pushReplacementNamed(context, '/home');
}
},
),
ListTile(
leading: const Icon(Icons.people),
title: const Text('Supplier'),
selected: selectedScreen == AppScreen.supplier,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.supplier) {
Navigator.pushReplacementNamed(context, '/supplier');
}
},
),
ListTile(
leading: const Icon(Icons.factory),
title: const Text('Manufacturer'),
selected: selectedScreen == AppScreen.manufacturer,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.manufacturer) {
Navigator.pushReplacementNamed(context, '/manufacturer');
}
},
),
ListTile(
leading: const Icon(Icons.location_on),
title: const Text('Station'),
selected: selectedScreen == AppScreen.station,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.station) {
Navigator.pushReplacementNamed(context, '/station');
}
},
),
ListTile(
leading: const Icon(Icons.inventory_2_rounded),
title: const Text('Product'),
selected: selectedScreen == AppScreen.product,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.product) {
Navigator.pushReplacementNamed(context, '/product');
}
},
),
ListTile(
leading: const Icon(Icons.category_rounded),
title: const Text('Item'),
selected: selectedScreen == AppScreen.item,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.item) {
Navigator.pushReplacementNamed(context, '/item');
}
},
),
ListTile(
leading: const Icon(Icons.swap_horiz_rounded),
title: const Text('Item Movement'),
selected: selectedScreen == AppScreen.itemMovement,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.itemMovement) {
Navigator.pushReplacementNamed(context, '/item_movement_all');
}
},
),
ListTile(
leading: const Icon(Icons.assignment_rounded),
title: const Text('Product Request'),
selected: selectedScreen == AppScreen.productRequest,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.productRequest) {
Navigator.pushReplacementNamed(context, '/product_request_t_to_im');
}
},
),
];
}
List<Widget> _buildUserListTiles(BuildContext context) {
return [
ListTile(
leading: const Icon(Icons.dashboard),
title: const Text('Dashboard'),
selected: selectedScreen == AppScreen.dashboard,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.dashboard) {
Navigator.pushReplacementNamed(context, '/user-home');
}
},
),
ListTile(
leading: const Icon(Icons.assignment),
title: const Text('Product Request'),
selected: selectedScreen == AppScreen.productRequestUser,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.productRequestUser) {
Navigator.pushReplacementNamed(context, '/product_request_user');
}
},
),
ListTile(
leading: const Icon(Icons.swap_horiz),
title: const Text('Item Movement'),
selected: selectedScreen == AppScreen.itemMovementUser,
onTap: () {
Navigator.pop(context);
if (selectedScreen != AppScreen.itemMovementUser) {
Navigator.pushReplacementNamed(context, '/item_movement_all_user');
}
},
),
];
}
}

View File

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'dart:async';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.6, curve: Curves.easeOut)),
);
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.8, curve: Curves.elasticOut)),
);
_slideAnimation =
Tween<Offset>(begin: const Offset(0, 0.5), end: Offset.zero).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.3, 1.0, curve: Curves.easeOutBack)),
);
_controller.forward();
Timer(const Duration(seconds: 3), () {
Navigator.pushReplacementNamed(context, '/login');
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF0D47A1),
Color(0xFF1976D2),
Color(0xFF42A5F5),
Color(0xFF90CAF9),
],
stops: [0.0, 0.3, 0.7, 1.0],
),
),
child: SafeArea(
child: Center(
child: FadeTransition(
opacity: _fadeAnimation,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ScaleTransition(
scale: _scaleAnimation,
child: Image.asset(
'assets/images/logo.png',
width: 380,
height: 380,
fit: BoxFit.contain,
gaplessPlayback: true,
),
),
Transform.translate(
offset: const Offset(0, -140),
child: SlideTransition(
position: _slideAnimation,
child: Text(
'INVENTORY\nSYSTEM',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w900,
letterSpacing: 4,
color: Colors.white,
height: 1.0,
shadows: [
Shadow(
offset: const Offset(2, 2),
blurRadius: 6,
color: Colors.black.withOpacity(0.4),
),
],
),
),
),
),
],
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
class TitleBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
const TitleBar({super.key, required this.title, this.actions});
@override
Widget build(BuildContext context) {
return AppBar(
backgroundColor: Colors.white,
elevation: 0,
iconTheme: const IconThemeData(color: Colors.black87), // For drawer icon
title: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
color: Colors.black87,
fontSize: 20,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
),
const SizedBox(width: 4),
if (title == 'Supplier')
Icon(Icons.local_shipping_rounded, color: Colors.grey.shade700, size: 20),
if (title == 'Manufacturer')
Icon(Icons.factory_rounded, color: Colors.grey.shade700, size: 20),
if (title == 'Station')
Icon(Icons.location_on_rounded, color: Colors.grey.shade700, size: 20),
if (title == 'Product')
Icon(Icons.inventory_2_rounded, color: Colors.grey.shade700, size: 20),
if (title == 'Item')
Icon(Icons.category_rounded, color: Colors.grey.shade700, size: 20),
if (title == 'Item Movement')
Icon(Icons.swap_horiz_rounded, color: Colors.grey.shade700, size: 20),
if (title == 'Product Request')
Icon(Icons.assignment_rounded, color: Colors.grey.shade700, size: 20),
// const SizedBox(width: 4),
// Text(
// title,
// style: const TextStyle(
// color: Colors.black87,
// fontSize: 20,
// fontWeight: FontWeight.w600,
// ),
// maxLines: 1,
// overflow: TextOverflow.ellipsis,
// softWrap: false,
// ),
],
),
),
actions: actions,
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@ -0,0 +1,362 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/utils/exit_dialog.dart';
import 'package:inventory_system/services/user_service.dart';
import 'package:inventory_system/services/report_service.dart';
class UserHomeScreen extends StatefulWidget {
const UserHomeScreen({super.key});
@override
State<UserHomeScreen> createState() => _UserHomeScreenState();
}
class _UserHomeScreenState extends State<UserHomeScreen> {
int _selectedIndex = 0;
Map<String, dynamic>? _reportData;
bool _isLoadingReport = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadReportData();
}
Future<void> _loadReportData() async {
setState(() {
_isLoadingReport = true;
_errorMessage = null;
});
try {
final userService = UserService();
final userInfoData = await userService.getUserInfo();
if (userInfoData.isEmpty || !userInfoData.containsKey('userInfo')) {
throw Exception("Failed to retrieve user information");
}
final userInfo = userInfoData['userInfo'];
final department = userInfo['department'];
int deptId = 0;
if (department != null && department['departmentId'] != null) {
deptId = department['departmentId'];
}
final reportService = ReportService();
final data = await reportService.fetchInventoryReport(deptId);
if (mounted) {
setState(() {
_reportData = data;
_isLoadingReport = false;
});
}
} catch (e) {
debugPrint("Error loading report data: $e");
if (mounted) {
setState(() {
_isLoadingReport = false;
_errorMessage = "Failed to load report";
});
}
}
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
final shouldExit = await showExitConfirmationDialog(context);
return shouldExit;
},
child: Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: const TitleBar(title: 'User Dashboard'),
drawer: const NavBar(isAdmin: false, selectedScreen: AppScreen.dashboard),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Information Section
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'INFORMATION',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.black87,
letterSpacing: 1,
),
),
if (_isLoadingReport) ...[
const SizedBox(width: 10),
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2)
),
]
],
),
),
const SizedBox(height: 16),
// Information Card
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.orange.shade300, Colors.orange.shade200],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.orange.shade400, width: 2),
boxShadow: [
BoxShadow(
color: Colors.orange.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.bold),
),
)
else ...[
Row(
children: [
Expanded(
child: _buildInfoItem('Total Items', _isLoadingReport ? '...' : '${_reportData?['itemCountRegistered'] ?? 0}'),
),
Container(
width: 1,
height: 40,
color: Colors.orange.shade400,
),
Expanded(
child: _buildInfoItem('Current Stock', _isLoadingReport ? '...' : '${_reportData?['itemCountStillInStock'] ?? 0}'),
),
],
),
const SizedBox(height: 16),
Container(
height: 1,
color: Colors.orange.shade400,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildInfoItem('New (Month)', _isLoadingReport ? '...' : '${_reportData?['itemCountRegisteredThisMonth'] ?? 0}'),
),
Container(
width: 1,
height: 40,
color: Colors.orange.shade400,
),
Expanded(
child: _buildInfoItem('Stock Out (Month)', _isLoadingReport ? '...' : '${_reportData?['itemCountStockOutThisMonth'] ?? 0}'),
),
],
),
],
],
),
),
const SizedBox(height: 24),
// Action Cards
Row(
children: [
Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
Navigator.pushNamed(context, '/product_request_user');
},
borderRadius: BorderRadius.circular(16),
splashColor: Colors.white.withOpacity(0.2),
highlightColor: Colors.white.withOpacity(0.1),
child: Ink(
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 16),
decoration: BoxDecoration(
color: Colors.blue.shade700,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.purple.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: const Column(
children: [
Icon(Icons.assignment_rounded, color: Colors.white, size: 48),
SizedBox(height: 12),
Text(
'Product Request',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
SizedBox(height: 4),
Text(
'Manage requests',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
Navigator.pushNamed(context, '/item_movement_all_user');
},
borderRadius: BorderRadius.circular(16),
splashColor: Colors.white.withOpacity(0.2),
highlightColor: Colors.white.withOpacity(0.1),
child: Ink(
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 16),
decoration: BoxDecoration(
color: Colors.blue.shade700,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.cyan.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: const Column(
children: [
Icon(Icons.swap_horiz_rounded, color: Colors.white, size: 48),
SizedBox(height: 12),
Text(
'Item Movement',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
SizedBox(height: 4),
Text(
'Track transfers',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
],
),
const SizedBox(height: 16),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
// isAdmin: false,
onItemTapped: (index) {
setState(() {
_selectedIndex = index;
});
},
),
),
);
}
Widget _buildTableHeader(String text) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
text,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
);
}
Widget _buildTableCell(String text, {bool isLeft = false}) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
text,
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade700,
),
textAlign: isLeft ? TextAlign.left : TextAlign.center,
),
);
}
Widget _buildInfoItem(String label, String value) {
return Column(
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
],
);
}
}

View File

@ -0,0 +1,562 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/services/item_movement_service.dart'; // Import service
import 'package:inventory_system/services/api_service.dart'; // Import ApiService for image base URL
import 'package:collection/collection.dart'; // Import for groupBy
class ItemMovementAllUserScreen extends StatefulWidget {
const ItemMovementAllUserScreen({super.key});
@override
State<ItemMovementAllUserScreen> createState() => _ItemMovementAllUserScreenState();
}
class _ItemMovementAllUserScreenState extends State<ItemMovementAllUserScreen>
with SingleTickerProviderStateMixin {
int _selectedIndex = 0;
late TabController _tabController;
String _selectedDropdownValue = 'All';
final List<String> _dropdownOptions = ['All', 'Item', 'Station'];
int? _expandedIndex;
Key _tabViewKey = UniqueKey();
final TextEditingController _pendingSearchController = TextEditingController();
final TextEditingController _completeSearchController = TextEditingController();
final TextEditingController _assignSearchController = TextEditingController();
List<Map<String, dynamic>> _allMovements = []; // Will hold fetched data
List<Map<String, dynamic>> _pendingFiltered = [];
List<Map<String, dynamic>> _completeFiltered = [];
List<Map<String, dynamic>> _assignFiltered = [];
final ItemMovementService _itemMovementService = ItemMovementService(); // Service instance
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_fetchInitialData(); // Call fetch data
_pendingSearchController.addListener(_applyFilters);
_completeSearchController.addListener(_applyFilters);
_assignSearchController.addListener(_applyFilters);
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
if (!_tabController.indexIsChanging && _expandedIndex != null) {
setState(() {
_expandedIndex = null;
_tabViewKey = UniqueKey();
});
}
});
}
Future<void> _fetchInitialData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final rawMovements = (await _itemMovementService.fetchItemMovements()).cast<Map<String, dynamic>>();
if (!mounted) return;
// 1. Group by UniqueID
final grouped = groupBy(rawMovements, (Map<String, dynamic> m) => m['uniqueID']);
List<Map<String, dynamic>> processedList = [];
// 2. Process each group to find the LATEST valid movement
for (var entry in grouped.entries) {
var movements = entry.value;
// Sort Newest to Oldest by date
movements.sort((a, b) {
final dateA = DateTime.tryParse(a['sendDate'] ?? a['date'] ?? '') ?? DateTime(0);
final dateB = DateTime.tryParse(b['sendDate'] ?? b['date'] ?? '') ?? DateTime(0);
return dateB.compareTo(dateA);
});
if (movements.isNotEmpty) {
final latest = movements.first;
// 3. Filter Logic: Hide if 'Return' or 'Ready To Deploy' is the CURRENT status
bool isReturn = latest['toOther'] == 'Return' && (latest['movementComplete'] == 1 || latest['movementComplete'] == true);
bool isReady = latest['latestStatus'] == 'Ready To Deploy' && (latest['movementComplete'] == 1 || latest['movementComplete'] == true);
if (!isReturn && !isReady) {
processedList.add(latest);
}
}
}
setState(() {
_allMovements = processedList;
_applyFilters(); // Apply filters on the processed list
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Failed to load data: $e';
_isLoading = false;
});
debugPrint('Error fetching initial data for ItemMovementAllUserScreen: $e');
}
}
@override
void dispose() {
_pendingSearchController.removeListener(_applyFilters);
_completeSearchController.removeListener(_applyFilters);
_assignSearchController.removeListener(_applyFilters);
_pendingSearchController.dispose();
_completeSearchController.dispose();
_assignSearchController.dispose();
_tabController.dispose();
super.dispose();
}
// Refactored _applyFilters to apply to _allMovements
void _applyFilters() {
final pendingQuery = _pendingSearchController.text.toLowerCase();
final completeQuery = _completeSearchController.text.toLowerCase();
final assignQuery = _assignSearchController.text.toLowerCase();
setState(() {
_pendingFiltered = _allMovements
.where((m) => !(m['movementComplete'] as bool? ?? false) && _matchesQuery(m, pendingQuery))
.toList();
_completeFiltered = _allMovements
.where((m) => (m['movementComplete'] as bool? ?? false) && _matchesQuery(m, completeQuery))
.toList();
_assignFiltered = _allMovements
.where((m) => m['action'] == 'Assign' && _matchesQuery(m, assignQuery))
.toList();
_expandedIndex = null;
});
}
bool _matchesQuery(Map<String, dynamic> m, String q) {
if (q.isEmpty) return true;
final keys = [
'productName', 'uniqueID', 'action', 'productCategory', 'latestStatus',
'toStationName', 'lastStationName', 'toStoreName', 'lastStoreName',
'toUserName', 'lastUserName', 'remark', 'consignmentNote', 'id'
];
for (final k in keys) {
final v = m[k]?.toString().toLowerCase();
if (v != null && v.contains(q)) return true;
}
return false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: TitleBar(
title: 'Item Movement',
actions: [
Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedDropdownValue,
icon: const Icon(Icons.keyboard_arrow_down),
items: _dropdownOptions.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (newValue) {
if (newValue == 'Item') {
Navigator.pushNamed(context, '/item_movement_item_user');
}
if (newValue == 'Station') {
Navigator.pushNamed(context, '/item_movement_station_user');
}
setState(() {
_selectedDropdownValue = newValue!;
});
},
),
),
),
],
),
drawer: const NavBar(isAdmin: false, selectedScreen: AppScreen.itemMovementUser),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Padding(padding: const EdgeInsets.all(16.0), child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontSize: 16), textAlign: TextAlign.center)))
: RefreshIndicator(
onRefresh: _fetchInitialData,
child: Column(
children: [
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
onTap: (index) {
setState(() {
_expandedIndex = null;
_tabViewKey = UniqueKey();
});
},
labelColor: Colors.purple.shade800,
unselectedLabelColor: Colors.grey.shade600,
indicatorColor: Colors.purple.shade800,
indicatorWeight: 3,
tabs: const [
Tab(text: 'Pending Item'),
Tab(text: 'Complete Item'),
Tab(text: 'Assign Station'),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
key: _tabViewKey,
children: [
_buildTabPage(
controller: _pendingSearchController,
items: _pendingFiltered,
tabType: 'pending',
),
_buildTabPage(
controller: _completeSearchController,
items: _completeFiltered,
tabType: 'complete',
),
_buildTabPage(
controller: _assignSearchController,
items: _assignFiltered,
tabType: 'assign',
),
],
),
),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) {
Navigator.pop(context);
} else if (index == 1) {
if (mounted) {
Navigator.pushNamed(context, '/scan');
}
} else {
setState(() {
_selectedIndex = index;
});
}
},
),
);
}
Widget _buildTabPage({
required TextEditingController controller,
required List<Map<String, dynamic>> items,
required String tabType,
}) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: controller,
onChanged: (_) {
_applyFilters(); // Call _applyFilters directly
},
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.clear();
_applyFilters(); // Call _applyFilters directly
},
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchInitialData,
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return _buildItemCard(item, index, tabType);
},
),
),
),
],
),
);
}
/// Builds a single item card with an expandable details section.
Widget _buildItemCard(Map<String, dynamic> item, int index, String tabType) {
final bool isExpanded = _expandedIndex == index;
final String imageUrl = item['productImage'] ?? ''; // Use productImage from API
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image + Product code (PID) under it
Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: imageUrl.isNotEmpty
? Image.network(
ApiService.baseUrl + imageUrl, // Use ApiService.baseUrl
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => const Icon(Icons.broken_image, color: Colors.grey),
)
: const Icon(Icons.image_not_supported, color: Colors.grey),
),
const SizedBox(height: 6),
Text(
item['uniqueID'] ?? '-', // Use uniqueID from API
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
const SizedBox(width: 16),
// Main Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['productName'] ?? '-', // Use productName from API
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text('ID: ${item['id'] ?? '-'}', style: TextStyle(color: Colors.grey.shade600)),
const SizedBox(height: 2),
Text(item['date'] ?? '-', style: TextStyle(color: Colors.grey.shade600)), // Use 'date' from API
],
),
),
],
),
),
// "More Details" Toggle Button
InkWell(
onTap: () {
setState(() {
// Only one open at a time
_expandedIndex = isExpanded ? null : index;
});
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
isExpanded ? 'Less Details' : 'More Details',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade600, // align with other pages
),
),
const SizedBox(width: 4),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
color: Colors.blue.shade600, // align with other pages
size: 20,
),
],
),
),
),
// The expandable section
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isExpanded
? _buildDetailsSection(item, tabType)
: const SizedBox.shrink(),
),
],
),
),
);
}
Widget _buildDetailsSection(Map<String, dynamic> item, String tabType) {
// Build tiles per requirements; Note (assign) shown below tiles.
List<Widget> tiles = [];
String? remark = item['remark']; // Use 'remark' from API
switch (tabType) {
case 'pending':
tiles = [
_buildDetailItem('Action', item['action'] ?? 'N/A'),
_buildDetailItem('Product Category', item['productCategory'] ?? 'N/A'), // Use productCategory
_buildDetailItem('Status', item['latestStatus'] ?? 'N/A'), // Use latestStatus
_buildDetailItem('From Station', item['lastStationName'] ?? 'N/A'),
_buildDetailItem('From Store', item['lastStoreName'] ?? 'N/A'),
_buildDetailItem('Quantity', item['quantity']?.toString() ?? 'N/A'),
_buildDetailItem('From User', item['lastUserName'] ?? 'N/A'),
_buildDetailItem('To User', item['toUserName'] ?? 'N/A'),
];
break;
case 'assign':
tiles = [
_buildDetailItem('Action', item['action'] ?? 'N/A'),
_buildDetailItem('Station User PIC', item['toUserName'] ?? 'N/A'), // Assume toUserName is PIC
_buildDetailItem('Quantity', item['quantity']?.toString() ?? 'N/A'),
_buildDetailItem('From Station', item['lastStationName'] ?? 'N/A'),
_buildDetailItem('To Station', item['toStationName'] ?? 'N/A'), // Use toStationName
_buildDetailItem('Last Store', item['lastStoreName'] ?? 'N/A'),
];
break;
case 'complete':
tiles = [
_buildDetailItem('Action', item['action'] ?? 'N/A'),
_buildDetailItem('Product Category', item['productCategory'] ?? 'N/A'),
_buildDetailItem('Quantity', item['quantity']?.toString() ?? 'N/A'),
_buildDetailItem('From Station', item['lastStationName'] ?? 'N/A'),
_buildDetailItem('To Station', item['toStationName'] ?? 'N/A'),
_buildDetailItem('From User', item['lastUserName'] ?? 'N/A'),
_buildDetailItem('To User', item['toUserName'] ?? 'N/A'),
_buildDetailItem('From Store', item['lastStoreName'] ?? 'N/A'),
_buildDetailItem('To Store', item['toStoreName'] ?? 'N/A'),
_buildDetailItem('Latest Status', item['latestStatus'] ?? 'N/A'),
];
break;
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: LayoutBuilder(
builder: (context, constraints) {
const double spacing = 16;
final double tileWidth = (constraints.maxWidth - (spacing * 2)) / 3;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: spacing,
runSpacing: 12,
children: tiles
.map((w) => SizedBox(width: tileWidth, child: w))
.toList(),
),
if (remark != null && remark.toString().trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'Remark', // Changed 'Note' to 'Remark'
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
remark,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
],
);
},
),
);
}
/// A helper widget to display a label and its value.
Widget _buildDetailItem(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
softWrap: true,
),
],
);
}
}

View File

@ -0,0 +1,717 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/services/item_movement_service.dart'; // Import service
import 'package:inventory_system/services/api_service.dart'; // Import ApiService for image base URL
import 'package:collection/collection.dart'; // For groupBy
class ItemMovementItemUserScreen extends StatefulWidget {
const ItemMovementItemUserScreen({super.key});
@override
State<ItemMovementItemUserScreen> createState() => _ItemMovementItemUserScreenState();
}
class _ItemMovementItemUserScreenState extends State<ItemMovementItemUserScreen> {
// Services
final ItemMovementService _itemMovementService = ItemMovementService();
// State
bool _isLoading = true;
String? _errorMessage;
List<Map<String, dynamic>> _allMovements = []; // Raw movements from service
List<Map<String, dynamic>> _groupedItems = []; // Grouped and sorted movements
List<Map<String, dynamic>> _filteredItems = []; // Filtered for search
String _selectedDropdownValue = 'Item';
final List<String> _dropdownOptions = ['All', 'Item', 'Station'];
final TextEditingController _searchController = TextEditingController();
int _selectedIndex = 0; // bottom nav index
int? _expandedItemIndex;
final Set<String> _expandedMovements = {};
final Set<String> _showFullHistory = {};
@override
void initState() {
super.initState();
_fetchInitialData();
_searchController.addListener(_applyFilter);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _fetchInitialData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
_allMovements = (await _itemMovementService.fetchItemMovements()).cast<Map<String, dynamic>>();
if (!mounted) return;
// Group movements by uniqueID
final grouped = groupBy(_allMovements, (Map<String, dynamic> m) => m['uniqueID']);
List<Map<String, dynamic>> processedItems = [];
for (var entry in grouped.entries) {
var movements = entry.value;
// Sort: Newest to Oldest by date
movements.sort((a, b) {
final dateA = DateTime.tryParse(a['sendDate'] ?? a['date'] ?? '') ?? DateTime(0);
final dateB = DateTime.tryParse(b['sendDate'] ?? b['date'] ?? '') ?? DateTime(0);
return dateB.compareTo(dateA);
});
// Filter Logic from Web App
// Find first occurrence of 'Return' complete
int stopIndex = movements.indexWhere((m) =>
m['toOther'] == 'Return' && (m['movementComplete'] == 1 || m['movementComplete'] == true)
);
// Find first occurrence of 'Ready To Deploy' complete
int nextIndex = movements.indexWhere((m) =>
m['latestStatus'] == 'Ready To Deploy' && (m['movementComplete'] == 1 || m['movementComplete'] == true)
);
if (stopIndex != -1) {
movements = movements.sublist(0, stopIndex);
}
if (nextIndex != -1) {
movements = movements.sublist(0, nextIndex);
}
if (movements.isNotEmpty) {
processedItems.add({
'pid': entry.key,
'uniqueID': movements.first['uniqueID'],
'history': movements,
'productName': movements.first['productName'],
'productImage': movements.first['productImage'],
});
}
}
setState(() {
_groupedItems = processedItems;
_filteredItems = List.of(_groupedItems);
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Failed to load data: $e';
_isLoading = false;
});
debugPrint('Error fetching initial data for ItemMovementItemUserScreen: $e');
}
}
void _applyFilter() {
final query = _searchController.text.trim().toLowerCase();
setState(() {
_filteredItems = _groupedItems
.where((item) => (item['pid'] as String?)?.toLowerCase().contains(query) ?? false)
.toList();
_expandedItemIndex = null;
_expandedMovements.clear();
_showFullHistory.clear();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: TitleBar(
title: 'Item Movement',
actions: [
_buildDropdown(),
],
),
drawer: const NavBar(isAdmin: false, selectedScreen: AppScreen.itemMovementUser),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Padding(padding: const EdgeInsets.all(16.0), child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontSize: 16), textAlign: TextAlign.center)))
: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
children: [
_buildSearchBar(),
const SizedBox(height: 16),
Expanded(
child: _filteredItems.isEmpty
? const Center(child: Text('No items found.'))
: RefreshIndicator(
onRefresh: _fetchInitialData,
child: ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
final item = _filteredItems[index];
final isExpanded = _expandedItemIndex == index;
return _buildItemCard(item, index, isExpanded);
},
),
),
),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) {
Navigator.pop(context);
} else if (index == 1) {
if (mounted) {
Navigator.pushNamed(context, '/scan');
}
} else {
setState(() {
_selectedIndex = index;
});
}
},
),
);
}
Widget _buildExpandedCardContent(Map<String, dynamic> item) {
final List<dynamic> history = item['history'];
final latestMovement = history.first as Map<String, dynamic>;
final pid = item['pid'] as String;
final bool isHistoryVisible = _showFullHistory.contains(pid);
return Padding(
padding: const EdgeInsets.all(12.0), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Latest Movement',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
_buildMovementDetailCard(latestMovement),
const SizedBox(height: 12),
if (history.length > 1)
InkWell(
onTap: () {
setState(() {
if (isHistoryVisible) {
_showFullHistory.remove(pid);
} else {
_showFullHistory.add(pid);
}
});
},
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isHistoryVisible ? 'Hide History' : 'View History',
style: TextStyle(
color: Colors.grey.shade800,
fontWeight: FontWeight.w600),
),
const SizedBox(width: 4),
Icon(
isHistoryVisible ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 18,
color: Colors.grey.shade800,
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isHistoryVisible
? _buildHistoryList(history)
: const SizedBox.shrink(),
),
],
),
);
}
Widget _buildHistoryList(List<dynamic> history) {
// Skip the first one as it is "Latest Movement"
final olderMovements = history.skip(1).toList();
return Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Column(
children: olderMovements.map((m) => Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: _buildMovementDetailCard(m as Map<String, dynamic>),
)).toList(),
),
);
}
// --- HELPER WIDGETS ---
Widget _buildDropdown() {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedDropdownValue,
icon: const Icon(Icons.keyboard_arrow_down),
items: _dropdownOptions.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (newValue) {
if (newValue == 'All') {
Navigator.pushReplacementNamed(context, '/item_movement_all_user');
}
if (newValue == 'Station') {
Navigator.pushReplacementNamed(context, '/item_movement_station_user');
}
setState(() {
_selectedDropdownValue = newValue!;
});
},
),
),
);
}
Widget _buildSearchBar() {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search by item code',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
);
}
Widget _buildItemCard(Map<String, dynamic> item, int index, bool isExpanded) {
final headerBg = isExpanded ? Colors.blue.shade50 : Colors.white;
final headerBorder = isExpanded ? Colors.blue.shade200 : Colors.grey.shade300;
final pid = item['pid'];
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: headerBorder),
),
child: Column(
children: [
InkWell(
onTap: () {
setState(() {
_expandedItemIndex = isExpanded ? null : index;
if (!isExpanded) {
_showFullHistory.remove(pid);
}
});
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: headerBg,
borderRadius: const BorderRadius.vertical(top: Radius.circular(11)),
border: Border(bottom: BorderSide(color: headerBorder)),
),
child: Row(
children: [
Expanded(
child: Text(
'Item : ${item['pid'] ?? 'N/A'}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
Text(
isExpanded ? 'Hide Details' : 'Show Details',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isExpanded ? Colors.blue.shade700 : Colors.grey.shade700,
),
),
const SizedBox(width: 4),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 20,
color: isExpanded ? Colors.blue.shade700 : Colors.grey.shade700,
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isExpanded
? _buildExpandedCardContent(item)
: const SizedBox.shrink(),
),
],
),
);
}
Widget _buildMovementDetailCard(Map<String, dynamic> movement) {
final movementId = movement['id'].toString(); // Ensure String key for Set
final bool isInnerExpanded = _expandedMovements.contains(movementId);
// --- Web App Logic for Labels and Colors ---
String toOther = movement['toOther'] ?? '';
String action = movement['action'] ?? '';
bool isToStation = movement['toStation'] != null;
bool isMovementComplete = (movement['movementComplete'] == 1 || movement['movementComplete'] == true);
String latestStatus = movement['latestStatus'] ?? '';
// 1. Status Heading
String statusHeading = 'Assign';
Color statusHeadingColor = Colors.blue;
if (toOther == 'Return') {
statusHeading = 'Return';
statusHeadingColor = Colors.orange; // Web: text-warning
} else if (toOther == 'On Delivery') {
statusHeading = 'Receive';
statusHeadingColor = Colors.blue; // Web: text-primary
} else if (isToStation) {
statusHeading = 'Change';
statusHeadingColor = Colors.green; // Web: text-success
} else {
// Default 'Assign' (text-info in web, usually light blue/cyan)
statusHeading = 'Assign';
statusHeadingColor = Colors.cyan;
}
// 2. Completion Status
String completionStatus = 'Incomplete';
Color completionColor = Colors.red;
if (isMovementComplete && latestStatus != 'Ready To Deploy') {
completionStatus = 'Complete';
completionColor = Colors.green; // Web: text-success
} else if (latestStatus == 'Ready To Deploy') {
completionStatus = 'Canceled';
completionColor = Colors.red;
} else {
completionStatus = 'Incomplete';
completionColor = Colors.red;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row: Status Heading | Dates | Completion
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left: Heading
Expanded(
flex: 2,
child: Text(
statusHeading,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: statusHeadingColor,
),
),
),
// Right: Completion & Expand
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
completionStatus,
style: TextStyle(
color: completionColor,
fontWeight: FontWeight.bold,
fontSize: 14
),
),
const SizedBox(height: 8),
_outlinedButton(
isInnerExpanded ? 'Hide Details' : 'Show Details',
Colors.blue.shade700,
onTap: () {
setState(() {
if (isInnerExpanded) {
_expandedMovements.remove(movementId);
} else {
_expandedMovements.add(movementId);
}
});
},
),
],
),
)
],
),
const SizedBox(height: 8),
// Dates and Action
_kvGrid('Send Date', _formatDate(movement['sendDate']),
'Receive Date', action != 'Assign' ? _formatDate(movement['receiveDate']) : 'Not arrive'),
const SizedBox(height: 4),
_kvGrid('Action', action, 'Status', latestStatus.isNotEmpty ? latestStatus : toOther),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
child: isInnerExpanded
? Column(
children: [
const SizedBox(height: 16),
_buildMovementFlow(movement),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _actionButton('Remark', isPrimary: true)),
const SizedBox(width: 12),
Expanded(child: _actionButton('Consignment Note')),
],
),
],
)
: const SizedBox.shrink(),
),
],
),
);
}
String _formatDate(String? dateStr) {
if (dateStr == null || dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
// Simple format: YYYY-MM-DD HH:MM:SS
return '${date.year}-${date.month.toString().padLeft(2,'0')}-${date.day.toString().padLeft(2,'0')} ${date.hour.toString().padLeft(2,'0')}:${date.minute.toString().padLeft(2,'0')}:${date.second.toString().padLeft(2,'0')}';
} catch (_) {
return dateStr;
}
}
Widget _buildMovementFlow(Map<String, dynamic> movement) {
// Start/End Logic from Web App
// Start Icon
IconData startIcon = Icons.warehouse;
bool isToStation = movement['toStation'] != null;
String toOther = movement['toOther'] ?? '';
if (isToStation) {
startIcon = Icons.location_on; // fas fa-map-marker-alt
} else if (toOther != 'On Delivery') {
startIcon = Icons.person; // fas fa-user
} else {
startIcon = Icons.warehouse; // fas fa-warehouse
}
// End Icon
IconData endIcon = Icons.warehouse;
bool isLastStation = movement['lastStation'] != null;
if (isLastStation) {
endIcon = Icons.location_on;
} else if (toOther != 'On Delivery') {
endIcon = Icons.warehouse;
} else {
endIcon = Icons.person;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_flowStep(startIcon, 'Start'),
Column(
children: [
const Icon(Icons.arrow_forward, size: 20, color: Colors.black54),
const SizedBox(height: 4),
Text(
movement['latestStatus'] ?? movement['toOther'] ?? '',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
_flowStep(endIcon, 'End'),
],
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// START COLUMN (Web uses to... variables)
Expanded(
child: Column(
children: [
if (movement['toUserName'] != null) _kvFlow('User:', movement['toUserName']),
if (movement['toStationName'] != null) _kvFlow('Station:', movement['toStationName']),
if (movement['toStoreName'] != null) _kvFlow('Store:', movement['toStoreName']),
],
),
),
const SizedBox(width: 16),
// END COLUMN (Web uses last... variables)
Expanded(
child: Column(
children: [
if (movement['lastUserName'] != null) _kvFlow('User:', movement['lastUserName']),
if (movement['lastStationName'] != null) _kvFlow('Station:', movement['lastStationName']),
if (movement['lastStoreName'] != null) _kvFlow('Store:', movement['lastStoreName']),
],
),
),
],
),
],
),
);
}
Widget _flowStep(IconData icon, String label) {
return Column(
children: [
Icon(icon, size: 28, color: Colors.blueGrey),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
],
);
}
Widget _kvGrid(String label1, String? value1, String label2, String? value2) {
return Row(
children: [
Expanded(child: _kv(label1, value1)),
Expanded(child: _kv(label2, value2)),
],
);
}
Widget _kv(String label, String? value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const SizedBox(height: 2),
Text(
value ?? '-',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
);
}
Widget _kvFlow(String label, String? value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
const SizedBox(width: 4),
Expanded(
child: Text(
value ?? '-',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
);
}
Widget _outlinedButton(String text, Color color, {VoidCallback? onTap}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withOpacity(0.7)),
),
child: Text(
text,
style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.bold),
),
),
);
}
Widget _actionButton(String text, {bool isPrimary = false}) {
return ElevatedButton(
onPressed: () {},
style: isPrimary
? ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade600,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
)
: OutlinedButton.styleFrom(
foregroundColor: Colors.blue.shade700,
side: BorderSide(color: Colors.blue.shade300),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(text),
);
}
}

View File

@ -0,0 +1,594 @@
import 'package:flutter/material.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/services/item_movement_service.dart';
import 'package:inventory_system/services/api_service.dart';
import 'package:collection/collection.dart';
class ItemMovementStationUserScreen extends StatefulWidget {
const ItemMovementStationUserScreen({super.key});
@override
State<ItemMovementStationUserScreen> createState() => _ItemMovementStationUserScreenState();
}
class _ItemMovementStationUserScreenState extends State<ItemMovementStationUserScreen> {
final ItemMovementService _itemMovementService = ItemMovementService();
bool _isLoading = true;
String? _errorMessage;
List<Map<String, dynamic>> _allMovements = [];
// Data Structure: Station Name -> List of Items (which contain history)
Map<String, List<Map<String, dynamic>>> _groupedByStation = {};
Map<String, List<Map<String, dynamic>>> _filteredStations = {}; // For display
String _selectedDropdownValue = 'Station';
final List<String> _dropdownOptions = ['All', 'Item', 'Station'];
final TextEditingController _searchController = TextEditingController();
int _selectedIndex = 0;
// Expansion States
final Set<String> _expandedStations = {};
final Set<String> _expandedItems = {}; // Key: "StationName_ItemPID"
final Set<String> _expandedMovements = {}; // Key: MovementID
final Set<String> _showFullHistory = {}; // Key: "StationName_ItemPID"
@override
void initState() {
super.initState();
_fetchInitialData();
_searchController.addListener(_applyFilter);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _fetchInitialData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
_allMovements = (await _itemMovementService.fetchItemMovements()).cast<Map<String, dynamic>>();
if (!mounted) return;
// 1. Group by UniqueID first
final groupedByItem = groupBy(_allMovements, (Map<String, dynamic> m) => m['uniqueID']);
Map<String, List<Map<String, dynamic>>> tempStationMap = {};
// 2. Process each item (sort, filter history)
for (var entry in groupedByItem.entries) {
var movements = entry.value;
movements.sort((a, b) {
final dateA = DateTime.tryParse(a['sendDate'] ?? a['date'] ?? '') ?? DateTime(0);
final dateB = DateTime.tryParse(b['sendDate'] ?? b['date'] ?? '') ?? DateTime(0);
return dateB.compareTo(dateA);
});
// Filter Logic
int stopIndex = movements.indexWhere((m) =>
m['toOther'] == 'Return' && (m['movementComplete'] == 1 || m['movementComplete'] == true)
);
int nextIndex = movements.indexWhere((m) =>
m['latestStatus'] == 'Ready To Deploy' && (m['movementComplete'] == 1 || m['movementComplete'] == true)
);
if (stopIndex != -1) movements = movements.sublist(0, stopIndex);
if (nextIndex != -1) movements = movements.sublist(0, nextIndex);
if (movements.isNotEmpty) {
final latest = movements.first;
// Determine Station
String station = latest['lastStationName'] ?? latest['toStationName'] ?? 'Self Assigned';
if (!tempStationMap.containsKey(station)) {
tempStationMap[station] = [];
}
tempStationMap[station]!.add({
'pid': entry.key,
'uniqueID': latest['uniqueID'],
'history': movements,
'productName': latest['productName'],
'productImage': latest['productImage'],
});
}
}
// Sort Stations (Self Assigned / Unassigned last)
var sortedKeys = tempStationMap.keys.toList()..sort((a, b) {
if (a == 'Self Assigned' || a == 'Unassign Station') return 1;
if (b == 'Self Assigned' || b == 'Unassign Station') return -1;
return a.compareTo(b);
});
Map<String, List<Map<String, dynamic>>> finalMap = {};
for (var key in sortedKeys) {
finalMap[key] = tempStationMap[key]!;
}
setState(() {
_groupedByStation = finalMap;
_filteredStations = Map.of(_groupedByStation);
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Failed to load data: $e';
_isLoading = false;
});
debugPrint('Error fetching data: $e');
}
}
void _applyFilter() {
final query = _searchController.text.trim().toLowerCase();
if (query.isEmpty) {
setState(() {
_filteredStations = Map.of(_groupedByStation);
});
return;
}
Map<String, List<Map<String, dynamic>>> filtered = {};
_groupedByStation.forEach((station, items) {
if (station.toLowerCase().contains(query)) {
filtered[station] = items;
}
});
setState(() {
_filteredStations = filtered;
// Auto expand if searching
_expandedStations.clear();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: TitleBar(
title: 'Item Movement',
actions: [ _buildDropdown() ],
),
drawer: const NavBar(isAdmin: false, selectedScreen: AppScreen.itemMovementUser),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Text(_errorMessage!, style: const TextStyle(color: Colors.red)))
: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
children: [
_buildSearchBar(),
const SizedBox(height: 16),
Expanded(
child: _filteredStations.isEmpty
? const Center(child: Text('No stations found.'))
: RefreshIndicator(
onRefresh: _fetchInitialData,
child: ListView.builder(
itemCount: _filteredStations.length,
itemBuilder: (context, index) {
String stationName = _filteredStations.keys.elementAt(index);
List<Map<String, dynamic>> items = _filteredStations[stationName]!;
return _buildStationCard(stationName, items);
},
),
),
),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
onItemTapped: (index) {
if (index == 0) Navigator.pop(context);
else if (index == 1) { if (mounted) Navigator.pushNamed(context, '/scan'); }
else setState(() => _selectedIndex = index);
},
),
);
}
Widget _buildStationCard(String stationName, List<Map<String, dynamic>> items) {
bool isExpanded = _expandedStations.contains(stationName);
final headerBg = isExpanded ? Colors.white : Colors.white; // Keep white for station card as per web app style (or slight gray)
// Web app: station-category card.
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: stationName == 'Unassign Station' || stationName == 'Self Assigned' ? Colors.grey.shade200 : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: 4, offset: const Offset(0, 2))]
),
child: Column(
children: [
InkWell(
onTap: () {
setState(() {
if (isExpanded) _expandedStations.remove(stationName);
else _expandedStations.add(stationName);
});
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Text(
stationName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
Text(
isExpanded ? 'Hide Items' : 'Show Items',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.blue.shade700),
),
const SizedBox(width: 4),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
color: Colors.blue.shade700,
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
child: isExpanded
? Column(
children: items.map((item) => _buildItemCard(item, stationName)).toList(),
)
: const SizedBox.shrink(),
)
],
),
);
}
Widget _buildItemCard(Map<String, dynamic> item, String stationName) {
final String itemKey = '${stationName}_${item['pid']}';
bool isExpanded = _expandedItems.contains(itemKey);
final headerBg = isExpanded ? Colors.blue.shade50 : Colors.white;
return Container(
margin: const EdgeInsets.fromLTRB(8, 0, 8, 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
children: [
InkWell(
onTap: () {
setState(() {
if (isExpanded) {
_expandedItems.remove(itemKey);
_showFullHistory.remove(itemKey);
} else {
_expandedItems.add(itemKey);
}
});
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: headerBg,
borderRadius: BorderRadius.vertical(top: Radius.circular(11), bottom: isExpanded ? Radius.zero : Radius.circular(11)),
),
child: Row(
children: [
Expanded(child: Text('Item : ${item['uniqueID']}', style: const TextStyle(fontWeight: FontWeight.bold))),
Text(isExpanded ? 'Hide Details' : 'Show Details', style: TextStyle(fontSize: 12, color: Colors.blue.shade700)),
Icon(isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, color: Colors.blue.shade700),
],
),
),
),
if (isExpanded) _buildExpandedCardContent(item, itemKey),
],
),
);
}
Widget _buildExpandedCardContent(Map<String, dynamic> item, String itemKey) {
final List<dynamic> history = item['history'];
final latestMovement = history.first as Map<String, dynamic>;
final bool isHistoryVisible = _showFullHistory.contains(itemKey);
return Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Latest Movement', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_buildMovementDetailCard(latestMovement),
const SizedBox(height: 12),
if (history.length > 1)
InkWell(
onTap: () {
setState(() {
if (isHistoryVisible) _showFullHistory.remove(itemKey);
else _showFullHistory.add(itemKey);
});
},
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(isHistoryVisible ? 'Hide History' : 'View History', style: TextStyle(color: Colors.grey.shade800, fontWeight: FontWeight.w600)),
const SizedBox(width: 4),
Icon(isHistoryVisible ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 18, color: Colors.grey.shade800),
],
),
),
),
if (isHistoryVisible)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Column(
children: history.skip(1).map((m) => Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: _buildMovementDetailCard(m as Map<String, dynamic>),
)).toList(),
),
),
],
),
);
}
// --- Shared Logic from ItemMovementItemUserScreen ---
Widget _buildMovementDetailCard(Map<String, dynamic> movement) {
final movementId = movement['id'].toString();
final bool isInnerExpanded = _expandedMovements.contains(movementId);
String toOther = movement['toOther'] ?? '';
String action = movement['action'] ?? '';
bool isToStation = movement['toStation'] != null;
bool isMovementComplete = (movement['movementComplete'] == 1 || movement['movementComplete'] == true);
String latestStatus = movement['latestStatus'] ?? '';
String statusHeading = 'Assign';
Color statusHeadingColor = Colors.blue;
if (toOther == 'Return') {
statusHeading = 'Return';
statusHeadingColor = Colors.orange;
} else if (toOther == 'On Delivery') {
statusHeading = 'Receive';
statusHeadingColor = Colors.blue;
} else if (isToStation) {
statusHeading = 'Change';
statusHeadingColor = Colors.green;
} else {
statusHeading = 'Assign';
statusHeadingColor = Colors.cyan;
}
String completionStatus = 'Incomplete';
Color completionColor = Colors.red;
if (isMovementComplete && latestStatus != 'Ready To Deploy') {
completionStatus = 'Complete';
completionColor = Colors.green;
} else if (latestStatus == 'Ready To Deploy') {
completionStatus = 'Canceled';
completionColor = Colors.red;
} else {
completionStatus = 'Incomplete';
completionColor = Colors.red;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
flex: 2,
child: Text(statusHeading, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: statusHeadingColor)),
),
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(completionStatus, style: TextStyle(color: completionColor, fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 8),
InkWell(
onTap: () => setState(() => isInnerExpanded ? _expandedMovements.remove(movementId) : _expandedMovements.add(movementId)),
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: Colors.blue.shade700.withOpacity(0.7))),
child: Text(isInnerExpanded ? 'Hide Details' : 'Show Details', style: TextStyle(color: Colors.blue.shade700, fontSize: 12, fontWeight: FontWeight.bold)),
),
),
],
),
)
],
),
const SizedBox(height: 8),
_kvGrid('Send Date', _formatDate(movement['sendDate']), 'Receive Date', action != 'Assign' ? _formatDate(movement['receiveDate']) : 'Not arrive'),
const SizedBox(height: 4),
_kvGrid('Action', action, 'Status', latestStatus.isNotEmpty ? latestStatus : toOther),
if (isInnerExpanded) ...[
const SizedBox(height: 16),
_buildMovementFlow(movement),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _actionButton('Remark', isPrimary: true)),
const SizedBox(width: 12),
Expanded(child: _actionButton('Consignment Note')),
],
),
]
],
),
);
}
Widget _buildMovementFlow(Map<String, dynamic> movement) {
IconData startIcon = Icons.warehouse;
bool isToStation = movement['toStation'] != null;
String toOther = movement['toOther'] ?? '';
if (isToStation) startIcon = Icons.location_on;
else if (toOther != 'On Delivery') startIcon = Icons.person;
else startIcon = Icons.warehouse;
IconData endIcon = Icons.warehouse;
bool isLastStation = movement['lastStation'] != null;
if (isLastStation) endIcon = Icons.location_on;
else if (toOther != 'On Delivery') endIcon = Icons.warehouse;
else endIcon = Icons.person;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.grey.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade200)),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_flowStep(startIcon, 'Start'),
Column(
children: [
const Icon(Icons.arrow_forward, size: 20, color: Colors.black54),
const SizedBox(height: 4),
Text(movement['latestStatus'] ?? movement['toOther'] ?? '', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
],
),
_flowStep(endIcon, 'End'),
],
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
children: [
if (movement['toUserName'] != null) _kvFlow('User:', movement['toUserName']),
if (movement['toStationName'] != null) _kvFlow('Station:', movement['toStationName']),
if (movement['toStoreName'] != null) _kvFlow('Store:', movement['toStoreName']),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
children: [
if (movement['lastUserName'] != null) _kvFlow('User:', movement['lastUserName']),
if (movement['lastStationName'] != null) _kvFlow('Station:', movement['lastStationName']),
if (movement['lastStoreName'] != null) _kvFlow('Store:', movement['lastStoreName']),
],
),
),
],
),
],
),
);
}
Widget _flowStep(IconData icon, String label) => Column(children: [Icon(icon, size: 28, color: Colors.blueGrey), const SizedBox(height: 4), Text(label, style: const TextStyle(fontWeight: FontWeight.bold))]);
Widget _kvGrid(String l1, String? v1, String l2, String? v2) => Row(children: [Expanded(child: _kv(l1, v1)), Expanded(child: _kv(l2, v2))]);
Widget _kv(String label, String? value) => Padding(padding: const EdgeInsets.symmetric(vertical: 4.0), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(label, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), const SizedBox(height: 2), Text(value ?? '-', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600))]));
Widget _kvFlow(String label, String? value) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(label, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), const SizedBox(width: 4), Expanded(child: Text(value ?? '-', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)))]);
Widget _actionButton(String text, {bool isPrimary = false}) {
return ElevatedButton(
onPressed: () {},
style: isPrimary
? ElevatedButton.styleFrom(backgroundColor: Colors.blue.shade600, foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))
: OutlinedButton.styleFrom(foregroundColor: Colors.blue.shade700, side: BorderSide(color: Colors.blue.shade300), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: Text(text),
);
}
Widget _buildDropdown() {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedDropdownValue,
icon: const Icon(Icons.keyboard_arrow_down),
items: _dropdownOptions.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (newValue) {
if (newValue == 'All') Navigator.pushReplacementNamed(context, '/item_movement_all_user');
if (newValue == 'Item') Navigator.pushReplacementNamed(context, '/item_movement_item_user');
setState(() => _selectedDropdownValue = newValue!);
},
),
),
);
}
Widget _buildSearchBar() {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search Station',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _searchController.clear()) : null,
filled: true, fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
);
}
String _formatDate(String? dateStr) {
if (dateStr == null || dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
return '${date.year}-${date.month.toString().padLeft(2,'0')}-${date.day.toString().padLeft(2,'0')} ${date.hour.toString().padLeft(2,'0')}:${date.minute.toString().padLeft(2,'0')}:${date.second.toString().padLeft(2,'0')}';
} catch (_) {
return dateStr;
}
}
}

View File

@ -0,0 +1,669 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
import 'package:inventory_system/screens/title_bar.dart';
import 'package:inventory_system/screens/user/product_request/product_request_user_form.dart';
import 'package:inventory_system/services/product_request_service.dart';
import 'package:inventory_system/services/api_service.dart';
class ProductRequestUserScreen extends StatefulWidget {
const ProductRequestUserScreen({super.key});
@override
State<ProductRequestUserScreen> createState() =>
_ProductRequestUserScreenState();
}
class _ProductRequestUserScreenState extends State<ProductRequestUserScreen> {
int _selectedIndex = 0;
String _selectedFilter = 'All';
final List<String> _filters = ['All', 'Requested', 'Approve', 'Rejected'];
final ProductRequestService _productRequestService = ProductRequestService();
List<Map<String, dynamic>> _allProducts = [];
List<Map<String, dynamic>> _filteredProducts = [];
bool _isLoading = true;
String? _errorMessage;
final TextEditingController _searchController = TextEditingController();
int? _expandedIndex;
@override
void initState() {
super.initState();
_fetchInitialData();
_searchController.addListener(_applyFilters);
}
Future<void> _fetchInitialData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final requests = await _productRequestService.fetchProductRequests();
// Sort by requestDate descending (latest first)
requests.sort((a, b) {
final dateA = DateTime.tryParse(a['requestDate'] ?? '') ?? DateTime(0);
final dateB = DateTime.tryParse(b['requestDate'] ?? '') ?? DateTime(0);
return dateB.compareTo(dateA);
});
if (!mounted) return;
setState(() {
_allProducts = requests;
_applyFilters();
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Failed to load requests: $e';
_isLoading = false;
});
}
}
@override
void dispose() {
_searchController.removeListener(_applyFilters);
_searchController.dispose();
super.dispose();
}
void _applyFilters() {
final q = _searchController.text.toLowerCase();
setState(() {
_filteredProducts = _allProducts.where((p) {
final name = (p['productName'] ?? '').toString().toLowerCase();
final status = (p['status'] ?? '').toString();
final matchesSearch = q.isEmpty || name.contains(q);
bool matchesFilter = false;
if (_selectedFilter == 'All') {
matchesFilter = true;
} else if (_selectedFilter == 'Requested') {
matchesFilter = status == 'Requested';
} else if (_selectedFilter == 'Approve') {
// Approve tab shows Approved or Complete, but NOT Rejected
matchesFilter = (status == 'Approved' || status == 'Complete') && status != 'Rejected';
} else if (_selectedFilter == 'Rejected') {
matchesFilter = status == 'Rejected';
}
return matchesSearch && matchesFilter;
}).toList();
_expandedIndex = null;
});
}
Future<void> _deleteRequest(int index) async {
final prod = _filteredProducts[index];
final requestId = prod['requestID'];
if (requestId == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Request'),
content: Text('Delete "${prod['productName']}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true) {
try {
await _productRequestService.deleteRequest(requestId);
_fetchInitialData();
} catch (e) {
if(mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
}
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F7),
appBar: const TitleBar(title: 'Product Request'),
drawer: const NavBar(isAdmin: false, selectedScreen: AppScreen.productRequestUser),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Text(_errorMessage!))
: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
_buildSearchBarAndFilters(),
const SizedBox(height: 16),
Expanded(
child: _filteredProducts.isEmpty
? _buildEmptyState()
: RefreshIndicator(
onRefresh: _fetchInitialData,
child: ListView.builder(
itemCount: _filteredProducts.length,
itemBuilder: (context, index) {
return _buildRequestCard(_filteredProducts[index], index);
},
),
),
),
],
),
),
bottomNavigationBar: BottomNavBar(
selectedIndex: _selectedIndex,
// isAdmin: false,
onItemTapped: (index) {
setState(() {
_selectedIndex = index;
});
},
),
);
}
Widget _buildSearchBarAndFilters() {
return Column(
children: [
Row(
children: [
ElevatedButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ProductRequestUserForm()),
).then((_) => _fetchInitialData()),
icon: const Icon(Icons.add, size: 18),
label: const Text('Add Request'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple.shade100,
foregroundColor: Colors.black87,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (context, index) => const SizedBox(width: 4),
itemBuilder: (context, index) {
final filter = _filters[index];
final isSelected = _selectedFilter == filter;
return InkWell(
onTap: () {
setState(() {
_selectedFilter = filter;
_applyFilters();
});
},
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: isSelected ? Colors.purple.shade100 : Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: isSelected
? Colors.purple.shade100
: Colors.grey.shade300,
),
),
child: Center(
child: Text(
filter,
style: TextStyle(
color: isSelected
? Colors.purple.shade900
: Colors.black54,
fontWeight: FontWeight.bold,
),
),
),
),
);
},
),
),
],
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 16.0, left: 4.0),
child: Text(
title,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text('No requests found',
style: TextStyle(color: Colors.grey.shade600)),
]),
);
}
Widget _buildRequestCard(Map<String, dynamic> product, int index) {
final isExpanded = _expandedIndex == index;
final status = product['status'];
final isApprove = status == 'Approved' || status == 'Complete';
final isRejected = status == 'Rejected';
final imageUrl = product['productPicture'] ?? product['productImage'];
Color statusColor;
if (isApprove) {
statusColor = Colors.green.shade800;
} else if (isRejected) {
statusColor = Colors.red.shade800;
} else {
statusColor = Colors.yellow.shade800; // Requested
}
Color statusBg = isApprove
? Colors.green.shade200
: (isRejected ? Colors.red.shade200 : Colors.yellow.shade500);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Slidable(
key: Key(product['requestID'].toString()),
endActionPane: (status == 'Requested')
? ActionPane(
motion: const BehindMotion(),
extentRatio: 0.25,
children: [
SlidableAction(
onPressed: (_) => _deleteRequest(index),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete,
label: 'Delete',
borderRadius: const BorderRadius.only(
topRight: Radius.circular(16),
bottomRight: Radius.circular(16),
),
),
],
)
: null,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
),
child: imageUrl != null && imageUrl.toString().isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
ApiService.baseUrl + imageUrl,
fit: BoxFit.cover,
errorBuilder: (ctx, err, stack) => const Icon(
Icons.broken_image,
color: Colors.grey),
),
)
: const Icon(Icons.image, color: Colors.grey),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(product['productName'] ?? 'Unknown',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold)),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
status ?? 'N/A',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
),
],
),
const SizedBox(height: 4),
Text('ID: ${product['requestID'] ?? '-'}',
style: TextStyle(
fontSize: 13, color: Colors.grey.shade600)),
const SizedBox(height: 2),
Text(product['requestDate'] ?? '-',
style: TextStyle(
fontSize: 13, color: Colors.grey.shade600)),
],
),
)
],
),
),
InkWell(
onTap: () =>
setState(() => _expandedIndex = isExpanded ? null : index),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
border:
Border(top: BorderSide(color: Colors.grey.shade200))),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(isExpanded ? 'Less Details' : 'More Details',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade700)),
const SizedBox(width: 6),
Icon(
isExpanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
color: Colors.blue.shade700,
size: 20)
]),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
child: isExpanded
? _buildDetailsSection(product, isApprove)
: const SizedBox.shrink(),
),
],
),
),
),
);
}
void _showDocument(String docPath) {
showDialog(
context: context,
builder: (ctx) => Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppBar(
title: const Text('Document'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(ctx),
),
elevation: 0,
backgroundColor: Colors.transparent,
foregroundColor: Colors.black,
),
InteractiveViewer(
child: Image.network(
ApiService.baseUrl + docPath,
fit: BoxFit.contain,
errorBuilder: (ctx, err, stack) => const Padding(
padding: EdgeInsets.all(20.0),
child: Column(
children: [
Icon(Icons.broken_image, size: 50, color: Colors.grey),
Text('Failed to load image'),
],
),
),
),
),
],
),
),
);
}
Widget _buildDocumentTile(String? docPath) {
final hasDoc = docPath != null && docPath.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Document/Picture',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
hasDoc
? InkWell(
onTap: () => _showDocument(docPath),
child: Text(
'Show',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade700,
decoration: TextDecoration.underline,
),
),
)
: const Text(
'No Document',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
);
}
Widget _buildDetailsSection(Map<String, dynamic> product, bool isApprove) {
final List<Widget> tiles = [];
final station = product['stationName'] ?? 'Self Assign';
if (isApprove) {
// Approve layout
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _detailTile(
'Product Category', product['productCategory'] ?? '-')),
const SizedBox(width: 16),
Expanded(
child: _detailTile('Request Quantity',
product['requestQuantity']?.toString() ?? '-')),
const SizedBox(width: 16),
Expanded(
child: _detailTile('Station Deploy', station)),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildDocumentTile(product['document'])),
const SizedBox(width: 16),
Expanded(
child: _detailTile(
'Remark', product['remarkUser'] ?? '-')),
const SizedBox(width: 16),
Expanded(
child: _detailTile(
'Remark (Master)', product['remarkMasterInv'] ?? '-')),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _detailTile(
'Approval Date', product['approvalDate'] ?? '-')),
],
),
],
),
);
} else {
// Requested layout
tiles.addAll([
_detailTile('Product Category', product['productCategory'] ?? '-'),
_detailTile('Request Quantity', product['requestQuantity']?.toString() ?? '-'),
_detailTile('Station Deploy', station),
_buildDocumentTile(product['document']),
_detailTile('Remark', product['remarkUser'] ?? '-'),
]);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: LayoutBuilder(
builder: (context, constraints) {
final maxW = constraints.maxWidth;
final int columns = 3;
const double spacing = 16;
final double tileW = (maxW - spacing * (columns - 1)) / columns;
// rough estimate for aspect ratio
final double childAspectRatio = 2.5;
return GridView(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: spacing,
mainAxisSpacing: 12,
childAspectRatio: childAspectRatio,
),
shrinkWrap: true,
primary: false,
physics: const NeverScrollableScrollPhysics(),
children: tiles,
);
},
),
);
}
}
Widget _detailTile(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
// overflow: TextOverflow.ellipsis,
softWrap: true,
),
],
);
}
}

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