first commit
43
.gitignore
vendored
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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 = "../.."
|
||||||
|
}
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
45
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.example.inventory_system
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity()
|
||||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||||
18
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
7
android/app/src/profile/AndroidManifest.xml
Normal 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
@ -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
|
||||||
|
}
|
||||||
8
android/gradle.properties
Normal 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
|
||||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@ -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
|
After Width: | Height: | Size: 42 KiB |
BIN
assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
assets/images/logo_small.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
assets/images/ram.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
assets/images/sontek.jpg
Normal file
|
After Width: | Height: | Size: 980 KiB |
BIN
assets/images/xdream.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
1289
database/pstw_cs (1).sql
Normal file
3
devtools_options.yaml
Normal 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
@ -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
|
||||||
26
ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||||
1
ios/Flutter/Debug.xcconfig
Normal file
@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
1
ios/Flutter/Release.xcconfig
Normal file
@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
616
ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
98
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal 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>
|
||||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
13
ios/Runner/AppDelegate.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal 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.
|
||||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal 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
@ -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>
|
||||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
12
ios/RunnerTests/RunnerTests.swift
Normal 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
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
lib/routes/slide_route.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
372
lib/screens/admin/home_screen/home_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
439
lib/screens/admin/item/item.dart
Normal 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))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
585
lib/screens/admin/item/item_form.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
536
lib/screens/admin/item_movement/item_movement_all.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
641
lib/screens/admin/item_movement/item_movement_item.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
343
lib/screens/admin/item_movement/item_movement_station.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
313
lib/screens/admin/manufacturer/manufacturer.dart
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
414
lib/screens/admin/product/product.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
372
lib/screens/admin/product/product_form.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
718
lib/screens/admin/product_request/invMaster_to_invMaster.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
388
lib/screens/admin/product_request/product_request_form.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
752
lib/screens/admin/product_request/technician_to_invMaster.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
lib/screens/admin/scan/scan.dart
Normal 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;
|
||||||
|
}
|
||||||
1067
lib/screens/admin/scan/scan_result.dart
Normal file
268
lib/screens/admin/station/station.dart
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
257
lib/screens/admin/station/station_form.dart
Normal 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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
353
lib/screens/admin/supplier/supplier.dart
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
lib/screens/admin/supplier/supplier_form.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
lib/screens/bottom_nav_bar.dart
Normal 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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
395
lib/screens/login_screen.dart
Normal 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
@ -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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
125
lib/screens/splash_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/screens/title_bar.dart
Normal 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);
|
||||||
|
}
|
||||||
362
lib/screens/user/home_screen/home_screen_user.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
562
lib/screens/user/item_movement/item_movement_all_user.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
717
lib/screens/user/item_movement/item_movement_item_user.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
594
lib/screens/user/item_movement/item_movement_station_user.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
669
lib/screens/user/product_request/product_request_user.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||