diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..9221e66b --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,81 @@ +# Run flutter linting checks + +name: test + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + + lint: + runs-on: ubuntu-latest + + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + submodules: recursive + - name: Setup Java + uses: actions/setup-java@v1 + with: + java-version: '12.x' + - name: Setup Flutter + uses: subosito/flutter-action@v1 + with: + flutter-version: '2.2.3' + - run: flutter pub get + - run: cp lib/dummy_dsn.dart lib/dsn.dart + - run: flutter analyze + - run: flutter test --coverage + + #android: + # runs-on: macos-latest + # + # steps: + # - name: Checkout code + # uses: actions/checkout@v2 + # with: + # submodules: recursive + # - name: Setup Java + # uses: actions/setup-java@v1 + # with: + # java-version: '12.x' + # - name: Setup Flutter + # uses: subosito/flutter-action@v1 + # with: + # flutter-version: '2.2.3' + # - name: Setup Gradle + # uses: gradle/gradle-build-action@v2 + # with: + # gradle-version: 6.1.1 + # - run: flutter pub get + # - run: cp lib/dummy_dsn.dart lib/dsn.dart + # - run: flutter build apk + + #ios: + # runs-on: macos-latest + # + # steps: + # - name: Checkout code + # uses: actions/checkout@v2 + # with: + # submodules: recursive + # - name: Setup Java + # uses: actions/setup-java@v1 + # with: + # java-version: '12.x' + # - name: Setup Flutter + # uses: subosito/flutter-action@v1 + # with: + # flutter-version: '2.2.3' + # - run: flutter pub get + # - run: cp lib/dummy_dsn.dart lib/dsn.dart + # - run: flutter build ios --release --no-codesign diff --git a/.gitignore b/.gitignore index 6c9c3b53..2604e33b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ .history .svn/ +coverage/* + # Sentry API key lib/dsn.dart diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..418cc57b --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,65 @@ +include: package:lint/analysis_options.yaml + +analyzer: + exclude: + - [build/**] + - lib/generated/** + language: + strict-raw-types: true + strong-mode: + implicit-casts: false + +linter: + rules: + # ------ Disable individual rules ----- # + # --- # + # Turn off what you don't like. # + # ------------------------------------- # + + # Make constructors the first thing in every class + sort_constructors_first: true + + prefer_double_quotes: true + + prefer_final_locals: false + + prefer_const_constructors: false + + prefer_final_in_for_each: false + + use_build_context_synchronously: false + + avoid_redundant_argument_values: false + + unnecessary_brace_in_string_interps: false + + unnecessary_string_interpolations: false + + prefer_interpolation_to_compose_strings: false + + no_logic_in_create_state: false + + parameter_assignments: false + + non_constant_identifier_names: false + + constant_identifier_names: false + + package_prefixed_library_names: false + + prefer_const_literals_to_create_immutables: false + + avoid_print: false + + avoid_positional_boolean_parameters: false + + prefer_final_fields: false + + sort_child_properties_last: false + + directives_ordering: false + + # Blindly follow the Flutter code style, which prefers types everywhere + always_specify_types: false + + avoid_unnecessary_containers: false diff --git a/android/build.gradle b/android/build.gradle index c5962208..af27b36c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,7 +8,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:4.2.0' + classpath 'com.android.tools.build:gradle:4.0.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 8b795a6c..9b9d49f3 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip \ No newline at end of file diff --git a/assets/release_notes.md b/assets/release_notes.md index 51ecff91..d6cd353f 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,15 @@ ## InvenTree App Release Notes --- +### 0.5.0 - October 2021 +--- + +- Display Purchase Order details +- Edit Purchase Order information +- Display Company details (supplier / manufacturer / customer) +- Edit Company information +- Fixed bug relating to stock transfer for parts with specified "units" + ### 0.4.7 - September 2021 --- diff --git a/lib/api.dart b/lib/api.dart index a0bf3d1b..64a74682 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -1,24 +1,25 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; +import "dart:async"; +import "dart:convert"; +import "dart:io"; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:intl/intl.dart'; +import "package:flutter/foundation.dart"; +import "package:http/http.dart" as http; +import "package:intl/intl.dart"; +import "package:inventree/app_colors.dart"; -import 'package:open_file/open_file.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import "package:open_file/open_file.dart"; +import "package:flutter/cupertino.dart"; +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:flutter_cache_manager/flutter_cache_manager.dart"; -import 'package:inventree/widget/dialogs.dart'; -import 'package:inventree/l10.dart'; -import 'package:inventree/inventree/sentry.dart'; -import 'package:inventree/user_profile.dart'; -import 'package:inventree/widget/snacks.dart'; -import 'package:path_provider/path_provider.dart'; +import "package:inventree/widget/dialogs.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/inventree/sentry.dart"; +import "package:inventree/user_profile.dart"; +import "package:inventree/widget/snacks.dart"; +import "package:path_provider/path_provider.dart"; /* @@ -49,7 +50,32 @@ class APIResponse { bool clientError() => (statusCode >= 400) && (statusCode < 500); - bool serverError() => (statusCode >= 500); + bool serverError() => statusCode >= 500; + + bool isMap() { + return data != null && data is Map; + } + + Map asMap() { + if (isMap()) { + return data as Map; + } else { + // Empty map + return {}; + } + } + + bool isList() { + return data != null && data is List; + } + + List asList() { + if (isList()) { + return data as List; + } else { + return []; + } + } } @@ -60,8 +86,6 @@ class APIResponse { */ class InvenTreeFileService extends FileService { - HttpClient? _client; - InvenTreeFileService({HttpClient? client, bool strictHttps = false}) { _client = client ?? HttpClient(); @@ -73,6 +97,8 @@ class InvenTreeFileService extends FileService { } } + HttpClient? _client; + @override Future get(String url, {Map? headers}) async { @@ -107,6 +133,12 @@ class InvenTreeFileService extends FileService { class InvenTreeAPI { + factory InvenTreeAPI() { + return _api; + } + + InvenTreeAPI._internal(); + // Minimum required API version for server static const _minApiVersion = 7; @@ -132,11 +164,12 @@ class InvenTreeAPI { String _makeUrl(String url) { // Strip leading slash - if (url.startsWith('/')) { + if (url.startsWith("/")) { url = url.substring(1, url.length); } - url = url.replaceAll('//', '/'); + // Prevent double-slash + url = url.replaceAll("//", "/"); return baseUrl + url; } @@ -149,7 +182,7 @@ class InvenTreeAPI { if (endpoint.startsWith("/api/") || endpoint.startsWith("api/")) { return _makeUrl(endpoint); } else { - return _makeUrl("/api/" + endpoint); + return _makeUrl("/api/${endpoint}"); } } @@ -184,10 +217,10 @@ class InvenTreeAPI { } // Server instance information - String instance = ''; + String instance = ""; // Server version information - String _version = ''; + String _version = ""; // API version of the connected server int _apiVersion = 1; @@ -209,15 +242,14 @@ class InvenTreeAPI { } // Ensure we only ever create a single instance of the API class - static final InvenTreeAPI _api = new InvenTreeAPI._internal(); + static final InvenTreeAPI _api = InvenTreeAPI._internal(); - factory InvenTreeAPI() { - return _api; + bool supportPoReceive() { + + // API endpoint for receiving purchase order line items was introduced in v12 + return _apiVersion >= 12; } - InvenTreeAPI._internal(); - - /* * Connect to the remote InvenTree server: * @@ -239,15 +271,15 @@ class InvenTreeAPI { if (address.isEmpty || username.isEmpty || password.isEmpty) { showSnackIcon( - "Incomplete profile details", + L10().incompleteDetails, icon: FontAwesomeIcons.exclamationCircle, success: false ); return false; } - if (!address.endsWith('/')) { - address = address + '/'; + if (!address.endsWith("/")) { + address = address + "/"; } /* TODO: Better URL validation * - If not a valid URL, return error @@ -267,8 +299,10 @@ class InvenTreeAPI { return false; } + var data = response.asMap(); + // We expect certain response from the server - if (response.data == null || !response.data.containsKey("server") || !response.data.containsKey("version") || !response.data.containsKey("instance")) { + if (!data.containsKey("server") || !data.containsKey("version") || !data.containsKey("instance")) { showServerError( L10().missingData, @@ -279,11 +313,11 @@ class InvenTreeAPI { } // Record server information - _version = response.data["version"]; - instance = response.data['instance'] ?? ''; + _version = (data["version"] ?? "") as String; + instance = (data["instance"] ?? "") as String; // Default API version is 1 if not provided - _apiVersion = (response.data['apiVersion'] ?? 1) as int; + _apiVersion = (data["apiVersion"] ?? 1) as int; if (_apiVersion < _minApiVersion) { @@ -332,7 +366,9 @@ class InvenTreeAPI { return false; } - if (response.data == null || !response.data.containsKey("token")) { + data = response.asMap(); + + if (!data.containsKey("token")) { showServerError( L10().tokenMissing, L10().tokenMissingFromResponse, @@ -342,7 +378,7 @@ class InvenTreeAPI { } // Return the received token - _token = response.data["token"]; + _token = (data["token"] ?? "") as String; print("Received token - $_token"); // Request user role information @@ -358,7 +394,7 @@ class InvenTreeAPI { _connected = false; _connecting = false; - _token = ''; + _token = ""; profile = null; } @@ -405,7 +441,7 @@ class InvenTreeAPI { // Next we request the permissions assigned to the current user // Note: 2021-02-27 this "roles" feature for the API was just introduced. - // Any 'older' version of the server allows any API method for any logged in user! + // Any "older" version of the server allows any API method for any logged in user! // We will return immediately, but request the user roles in the background var response = await get(_URL_GET_ROLES, expectedStatusCode: 200); @@ -414,9 +450,11 @@ class InvenTreeAPI { return; } - if (response.data.containsKey('roles')) { + var data = response.asMap(); + + if (data.containsKey("roles")) { // Save a local copy of the user roles - roles = response.data['roles']; + roles = response.data["roles"] as Map; } } @@ -424,7 +462,7 @@ class InvenTreeAPI { /* * Check if the user has the given role.permission assigned *e - * e.g. 'part', 'change' + * e.g. "part", "change" */ // If we do not have enough information, assume permission is allowed @@ -437,7 +475,7 @@ class InvenTreeAPI { } try { - List perms = List.from(roles[role]); + List perms = List.from(roles[role] as List); return perms.contains(permission); } catch (error, stackTrace) { sentryReportError(error, stackTrace); @@ -447,19 +485,17 @@ class InvenTreeAPI { // Perform a PATCH request - Future patch(String url, {Map body = const {}, int? expectedStatusCode}) async { - var _body = Map(); + Future patch(String url, {Map body = const {}, int? expectedStatusCode}) async { - // Copy across provided data - body.forEach((K, V) => _body[K] = V); + Map _body = body; HttpClientRequest? request = await apiRequest(url, "PATCH"); if (request == null) { // Return an "invalid" APIResponse - return new APIResponse( + return APIResponse( url: url, - method: 'PATCH', + method: "PATCH", error: "HttpClientRequest is null" ); } @@ -503,7 +539,7 @@ class InvenTreeAPI { HttpClientRequest? _request; - var client = createClient(true); + var client = createClient(allowBadCert: true); // Attempt to open a connection to the server try { @@ -511,8 +547,8 @@ class InvenTreeAPI { // Set headers _request.headers.set(HttpHeaders.authorizationHeader, _authorizationHeader()); - _request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - _request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); + _request.headers.set(HttpHeaders.acceptHeader, "application/json"); + _request.headers.set(HttpHeaders.contentTypeHeader, "application/json"); _request.headers.set(HttpHeaders.acceptLanguageHeader, Intl.getCurrentLocale()); } on SocketException catch (error) { @@ -550,7 +586,7 @@ class InvenTreeAPI { showServerError(L10().connectionRefused, error.toString()); } on TimeoutException { showTimeoutError(); - } catch (error, stackTrace) { + } catch (error) { print("Error downloading image:"); print(error.toString()); showServerError(L10().downloadError, error.toString()); @@ -561,7 +597,7 @@ class InvenTreeAPI { * Upload a file to the given URL */ Future uploadFile(String url, File f, - {String name = "attachment", String method="POST", Map? fields}) async { + {String name = "attachment", String method="POST", Map? fields}) async { var _url = makeApiUrl(url); var request = http.MultipartRequest(method, Uri.parse(_url)); @@ -569,8 +605,13 @@ class InvenTreeAPI { request.headers.addAll(defaultHeaders()); if (fields != null) { - fields.forEach((String key, String value) { - request.fields[key] = value; + fields.forEach((String key, dynamic value) { + + if (value == null) { + request.fields[key] = ""; + } else { + request.fields[key] = value.toString(); + } }); } @@ -652,9 +693,9 @@ class InvenTreeAPI { if (request == null) { // Return an "invalid" APIResponse - return new APIResponse( + return APIResponse( url: url, - method: 'OPTIONS' + method: "OPTIONS" ); } @@ -671,9 +712,9 @@ class InvenTreeAPI { if (request == null) { // Return an "invalid" APIResponse - return new APIResponse( + return APIResponse( url: url, - method: 'POST' + method: "POST" ); } @@ -684,15 +725,13 @@ class InvenTreeAPI { ); } - HttpClient createClient(bool allowBadCert) { + HttpClient createClient({bool allowBadCert = true}) { - var client = new HttpClient(); + var client = HttpClient(); - client.badCertificateCallback = ((X509Certificate cert, String host, int port) { + client.badCertificateCallback = (X509Certificate cert, String host, int port) { // TODO - Introspection of actual certificate? - allowBadCert = true; - if (allowBadCert) { return true; } else { @@ -702,7 +741,7 @@ class InvenTreeAPI { ); return false; } - }); + }; // Set the connection timeout client.connectionTimeout = Duration(seconds: 30); @@ -714,7 +753,7 @@ class InvenTreeAPI { * Initiate a HTTP request to the server * * @param url is the API endpoint - * @param method is the HTTP method e.g. 'POST' / 'PATCH' / 'GET' etc; + * @param method is the HTTP method e.g. "POST" / "PATCH" / "GET" etc; * @param params is the request parameters */ Future apiRequest(String url, String method, {Map urlParams = const {}}) async { @@ -731,7 +770,7 @@ class InvenTreeAPI { } // Remove extraneous character if present - if (_url.endsWith('&')) { + if (_url.endsWith("&")) { _url = _url.substring(0, _url.length - 1); } @@ -749,7 +788,7 @@ class InvenTreeAPI { HttpClientRequest? _request; - var client = createClient(true); + var client = createClient(allowBadCert: true); // Attempt to open a connection to the server try { @@ -757,8 +796,8 @@ class InvenTreeAPI { // Set headers _request.headers.set(HttpHeaders.authorizationHeader, _authorizationHeader()); - _request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - _request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); + _request.headers.set(HttpHeaders.acceptHeader, "application/json"); + _request.headers.set(HttpHeaders.contentTypeHeader, "application/json"); _request.headers.set(HttpHeaders.acceptLanguageHeader, Intl.getCurrentLocale()); return _request; @@ -792,7 +831,7 @@ class InvenTreeAPI { request.add(encoded_data); } - APIResponse response = new APIResponse( + APIResponse response = APIResponse( method: request.method, url: request.uri.toString() ); @@ -805,6 +844,19 @@ class InvenTreeAPI { // If the server returns a server error code, alert the user if (_response.statusCode >= 500) { showStatusCodeError(_response.statusCode); + + sentryReportMessage( + "Server error", + context: { + "url": request.uri.toString(), + "method": request.method, + "statusCode": _response.statusCode.toString(), + "requestHeaders": request.headers.toString(), + "responseHeaders": _response.headers.toString(), + "responseData": response.data.toString(), + } + ); + } else { response.data = await responseToJson(_response) ?? {}; @@ -814,21 +866,6 @@ class InvenTreeAPI { if (statusCode != _response.statusCode) { showStatusCodeError(_response.statusCode); } - - // Report any server errors - if (_response.statusCode >= 500) { - sentryReportMessage( - "Server error", - context: { - "url": request.uri.toString(), - "method": request.method, - "statusCode": _response.statusCode.toString(), - "requestHeaders": request.headers.toString(), - "responseHeaders": _response.headers.toString(), - "responseData": response.data.toString(), - } - ); - } } } @@ -898,9 +935,9 @@ class InvenTreeAPI { if (request == null) { // Return an "invalid" APIResponse - return new APIResponse( + return APIResponse( url: url, - method: 'GET', + method: "GET", error: "HttpClientRequest is null", ); } @@ -910,11 +947,11 @@ class InvenTreeAPI { // Return a list of request headers Map defaultHeaders() { - var headers = Map(); + Map headers = {}; headers[HttpHeaders.authorizationHeader] = _authorizationHeader(); - headers[HttpHeaders.acceptHeader] = 'application/json'; - headers[HttpHeaders.contentTypeHeader] = 'application/json'; + headers[HttpHeaders.acceptHeader] = "application/json"; + headers[HttpHeaders.contentTypeHeader] = "application/json"; headers[HttpHeaders.acceptLanguageHeader] = Intl.getCurrentLocale(); return headers; @@ -924,7 +961,7 @@ class InvenTreeAPI { if (_token.isNotEmpty) { return "Token $_token"; } else if (profile != null) { - return "Basic " + base64Encode(utf8.encode('${profile?.username}:${profile?.password}')); + return "Basic " + base64Encode(utf8.encode("${profile?.username}:${profile?.password}")); } else { return ""; } @@ -954,10 +991,10 @@ class InvenTreeAPI { ) ); - return new CachedNetworkImage( + return CachedNetworkImage( imageUrl: url, placeholder: (context, url) => CircularProgressIndicator(), - errorWidget: (context, url, error) => Icon(FontAwesomeIcons.exclamation), + errorWidget: (context, url, error) => FaIcon(FontAwesomeIcons.timesCircle, color: COLOR_DANGER), httpHeaders: defaultHeaders(), height: height, width: width, diff --git a/lib/api_form.dart b/lib/api_form.dart index 88ffa885..c2814d99 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1,22 +1,25 @@ -import 'dart:ui'; -import 'dart:io'; +import "dart:ui"; +import "dart:io"; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:dropdown_search/dropdown_search.dart'; +import "package:intl/intl.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:dropdown_search/dropdown_search.dart"; +import "package:datetime_picker_formfield/datetime_picker_formfield.dart"; -import 'package:inventree/api.dart'; -import 'package:inventree/app_colors.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/inventree/sentry.dart'; -import 'package:inventree/inventree/stock.dart'; -import 'package:inventree/widget/dialogs.dart'; -import 'package:inventree/widget/fields.dart'; -import 'package:inventree/l10.dart'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:inventree/widget/snacks.dart'; +import "package:inventree/api.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/barcode.dart"; +import "package:inventree/helpers.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/inventree/sentry.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/widget/dialogs.dart"; +import "package:inventree/widget/fields.dart"; +import "package:inventree/l10.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:inventree/widget/snacks.dart"; /* @@ -35,55 +38,138 @@ class APIFormField { final String name; // JSON data which defines the field - final dynamic data; + final Map data; + + // JSON field definition provided by the server + Map definition = {}; dynamic initial_data; + // Return the "lookup path" for this field, within the server data + String get lookupPath { + + // Simple top-level case + if (parent.isEmpty && !nested) { + return name; + } + + List path = []; + + if (parent.isNotEmpty) { + path.add(parent); + path.add("child"); + } + + if (nested) { + path.add("children"); + path.add(name); + } + + return path.join("."); + } + + /* + * Extract a field parameter from the provided field definition. + * + * - First the user-provided data is checked + * - Second, the server-provided definition is checked + * + * - Finally, return null + */ + dynamic getParameter(String key) { + if (data.containsKey(key)) { + return data[key]; + } else if (definition.containsKey(key)) { + return definition[key]; + } else { + return null; + } + } + // Get the "api_url" associated with a related field - String get api_url => data["api_url"] ?? ""; + String get api_url => (getParameter("api_url") ?? "") as String; // Get the "model" associated with a related field - String get model => data["model"] ?? ""; + String get model => (getParameter("model") ?? "") as String; // Is this field hidden? - bool get hidden => (data['hidden'] ?? false) as bool; + bool get hidden => (getParameter("hidden") ?? false) as bool; + + // Is this field nested? (Nested means part of an array) + // Note: This parameter is only defined locally + bool get nested => (data["nested"] ?? false) as bool; + + // What is the "parent" field of this field? + // Note: This parameter is only defined locally + String get parent => (data["parent"] ?? "") as String; + + bool get isSimple => !nested && parent.isEmpty; // Is this field read only? - bool get readOnly => (data['read_only'] ?? false) as bool; + bool get readOnly => (getParameter("read_only") ?? false) as bool; - bool get multiline => (data['multiline'] ?? false) as bool; + bool get multiline => (getParameter("multiline") ?? false) as bool; // Get the "value" as a string (look for "default" if not available) - dynamic get value => (data['value'] ?? data['default']); + dynamic get value => data["value"] ?? data["instance_value"] ?? defaultValue; + + // Render value to string (for form submission) + String renderValueToString() { + if (data["value"] == null) { + return ""; + } else { + return data["value"].toString(); + } + } // Get the "default" as a string - dynamic get defaultValue => data['default']; + dynamic get defaultValue => getParameter("default"); + // Construct a set of "filters" for this field (e.g. related field) Map get filters { Map _filters = {}; - // Start with the provided "model" filters - if (data.containsKey("filters")) { + // Start with the field "definition" (provided by the server) + if (definition.containsKey("filters")) { - dynamic f = data["filters"]; + try { + var fDef = definition["filters"] as Map; - if (f is Map) { - f.forEach((key, value) { + fDef.forEach((String key, dynamic value) { _filters[key] = value.toString(); }); + + } catch (error) { + // pass } } - // Now, look at the provided "instance_filters" - if (data.containsKey("instance_filters")) { + // Next, look at any "instance_filters" provided by the server + if (definition.containsKey("instance_filters")) { - dynamic f = data["instance_filters"]; + try { + var fIns = definition["instance_filters"] as Map; - if (f is Map) { - f.forEach((key, value) { + fIns.forEach((String key, dynamic value) { _filters[key] = value.toString(); }); + } catch (error) { + // pass + } + + } + + // Finally, augment or override with any filters provided by the calling function + if (data.containsKey("filters")) { + try { + var fDat = data["filters"] as Map; + + fDat.forEach((String key, dynamic value) { + _filters[key] = value.toString(); + }); + } catch (error) { + // pass } } @@ -91,11 +177,45 @@ class APIFormField { } - bool hasErrors() => errorMessages().length > 0; + bool hasErrors() => errorMessages().isNotEmpty; + + // Extract error messages from the server response + void extractErrorMessages(APIResponse response) { + + dynamic errors; + + if (isSimple) { + // Simple fields are easily handled + errors = response.data[name]; + } else { + if (parent.isNotEmpty) { + dynamic parentElement = response.data[parent]; + + // Extract from list + if (parentElement is List) { + parentElement = parentElement[0]; + } + + if (parentElement is Map) { + errors = parentElement[name]; + } + } + } + + data["errors"] = errors; + } // Return the error message associated with this field List errorMessages() { - List errors = data['errors'] ?? []; + + dynamic errors = data["errors"] ?? []; + + // Handle the case where a single error message is returned + if (errors is String) { + errors = [errors]; + } + + errors = errors as List; List messages = []; @@ -107,17 +227,17 @@ class APIFormField { } // Is this field required? - bool get required => (data['required'] ?? false) as bool; + bool get required => (getParameter("required") ?? false) as bool; - String get type => (data['type'] ?? '').toString(); + String get type => (getParameter("type") ?? "").toString(); - String get label => (data['label'] ?? '').toString(); + String get label => (getParameter("label") ?? "").toString(); - String get helpText => (data['help_text'] ?? '').toString(); + String get helpText => (getParameter("help_text") ?? "").toString(); - String get placeholderText => (data['placeholder'] ?? '').toString(); + String get placeholderText => (getParameter("placeholder") ?? "").toString(); - List get choices => data["choices"] ?? []; + List get choices => (getParameter("choices") ?? []) as List; Future loadInitialData() async { @@ -150,7 +270,7 @@ class APIFormField { } // Construct a widget for this input - Widget constructField() { + Widget constructField(BuildContext context) { switch (type) { case "string": case "url": @@ -167,6 +287,10 @@ class APIFormField { case "file upload": case "image upload": return _constructFileField(); + case "date": + return _constructDateField(); + case "barcode": + return _constructBarcodeField(context); default: return ListTile( title: Text( @@ -179,10 +303,102 @@ class APIFormField { } } + // Field for capturing a barcode + Widget _constructBarcodeField(BuildContext context) { + + TextEditingController controller = TextEditingController(); + + String barcode = (value ?? "").toString(); + + if (barcode.isEmpty) { + barcode = L10().barcodeNotAssigned; + } + + controller.text = barcode; + + return InputDecorator( + decoration: InputDecoration( + labelText: required ? label + "*" : label, + labelStyle: _labelStyle(), + helperText: helpText, + helperStyle: _helperStyle(), + hintText: placeholderText, + ), + child: ListTile( + title: TextField( + readOnly: true, + controller: controller, + ), + trailing: IconButton( + icon: FaIcon(FontAwesomeIcons.qrcode), + onPressed: () async { + var handler = UniqueBarcodeHandler((String hash) { + controller.text = hash; + data["value"] = hash; + + successTone(); + + showSnackIcon( + L10().barcodeAssigned, + success: true + ); + }); + + Navigator.push( + context, + MaterialPageRoute(builder: (context) => InvenTreeQRView(handler) + ) + ); + }, + ), + ) + ); + + } + + // Field for displaying and selecting dates + Widget _constructDateField() { + + DateTime? currentDate = DateTime.tryParse((value ?? "")as String); + + return InputDecorator( + decoration: InputDecoration( + labelText: label, + labelStyle: _labelStyle(), + helperStyle: _helperStyle(), + helperText: helpText, + ), + child: DateTimeField( + format: DateFormat("yyyy-MM-dd"), + initialValue: currentDate, + onChanged: (DateTime? time) { + // Save the time string + if (time == null) { + data["value"] = null; + } else { + data["value"] = time.toString().split(" ").first; + } + }, + onShowPicker: (context, value) async { + final time = await showDatePicker( + context: context, + initialDate: currentDate ?? DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime(2100), + ); + + return time; + }, + ) + ); + + } + + // Field for selecting and uploading files Widget _constructFileField() { - TextEditingController controller = new TextEditingController(); + TextEditingController controller = TextEditingController(); controller.text = (attachedfile?.path ?? L10().attachmentSelect).split("/").last; @@ -202,7 +418,6 @@ class APIFormField { FilePickerDialog.pickFile( message: L10().attachmentSelect, onPicked: (file) { - print("${file.path}"); // Display the filename controller.text = file.path.split("/").last; @@ -223,7 +438,7 @@ class APIFormField { // Check if the current value is within the allowed values for (var opt in choices) { - if (opt['value'] == value) { + if (opt["value"] == value) { _initial = opt; break; } @@ -240,13 +455,13 @@ class APIFormField { autoFocusSearchBox: true, showClearButton: !required, itemAsString: (dynamic item) { - return item['display_name']; + return (item["display_name"] ?? "") as String; }, onSaved: (item) { if (item == null) { - data['value'] = null; + data["value"] = null; } else { - data['value'] = item['value']; + data["value"] = item["value"]; } } ); @@ -263,11 +478,11 @@ class APIFormField { helperStyle: _helperStyle(), hintText: placeholderText, ), - initialValue: (value ?? 0).toString(), + initialValue: simpleNumberString(double.tryParse(value.toString()) ?? 0), keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true), validator: (value) { - double? quantity = double.tryParse(value.toString()) ?? null; + double? quantity = double.tryParse(value.toString()); if (quantity == null) { return L10().numberInvalid; @@ -308,7 +523,7 @@ class APIFormField { List results = []; - for (var result in response.data['results'] ?? []) { + for (var result in response.data["results"] ?? []) { results.add(result); } @@ -322,13 +537,16 @@ class APIFormField { onChanged: null, showClearButton: !required, itemAsString: (dynamic item) { + + Map data = item as Map; + switch (model) { case "part": - return InvenTreePart.fromJson(item).fullname; + return InvenTreePart.fromJson(data).fullname; case "partcategory": - return InvenTreePartCategory.fromJson(item).pathstring; + return InvenTreePartCategory.fromJson(data).pathstring; case "stocklocation": - return InvenTreeStockLocation.fromJson(item).pathstring; + return InvenTreeStockLocation.fromJson(data).pathstring; default: return "itemAsString not implemented for '${model}'"; } @@ -341,9 +559,9 @@ class APIFormField { }, onSaved: (item) { if (item != null) { - data['value'] = item['pk'] ?? null; + data["value"] = item["pk"]; } else { - data['value'] = null; + data["value"] = null; } }, isFilteredOnline: true, @@ -356,7 +574,7 @@ class APIFormField { return false; } - return item['pk'] == selectedItem['pk']; + return item["pk"] == selectedItem["pk"]; } ); } @@ -364,19 +582,13 @@ class APIFormField { Widget _renderRelatedField(dynamic item, bool selected, bool extended) { // Render a "related field" based on the "model" type - if (item == null) { - return Text( - helpText, - style: TextStyle( - fontStyle: FontStyle.italic - ), - ); - } + // Convert to JSON + var data = Map.from((item ?? {}) as Map); switch (model) { case "part": - var part = InvenTreePart.fromJson(item); + var part = InvenTreePart.fromJson(data); return ListTile( title: Text( @@ -392,7 +604,7 @@ class APIFormField { case "partcategory": - var cat = InvenTreePartCategory.fromJson(item); + var cat = InvenTreePartCategory.fromJson(data); return ListTile( title: Text( @@ -406,7 +618,7 @@ class APIFormField { ); case "stocklocation": - var loc = InvenTreeStockLocation.fromJson(item); + var loc = InvenTreeStockLocation.fromJson(data); return ListTile( title: Text( @@ -418,6 +630,13 @@ class APIFormField { style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal), ) : null, ); + case "owner": + String name = (data["name"] ?? "") as String; + bool isGroup = (data["label"] ?? "") == "group"; + return ListTile( + title: Text(name), + leading: FaIcon(isGroup ? FontAwesomeIcons.users : FontAwesomeIcons.user), + ); default: return ListTile( title: Text( @@ -447,7 +666,7 @@ class APIFormField { readOnly: readOnly, maxLines: multiline ? null : 1, expands: false, - initialValue: value ?? '', + initialValue: (value ?? "") as String, onSaved: (val) { data["value"] = val; }, @@ -467,15 +686,15 @@ class APIFormField { labelStyle: _labelStyle(), helperText: helpText, helperStyle: _helperStyle(), - initial: value, + initial: value as bool, onSaved: (val) { - data['value'] = val; + data["value"] = val; }, ); } TextStyle _labelStyle() { - return new TextStyle( + return TextStyle( fontWeight: FontWeight.bold, fontSize: 18, fontFamily: "arial", @@ -485,7 +704,7 @@ class APIFormField { } TextStyle _helperStyle() { - return new TextStyle( + return TextStyle( fontStyle: FontStyle.italic, color: hasErrors() ? COLOR_DANGER : COLOR_GRAY, ); @@ -503,15 +722,85 @@ Map extractFields(APIResponse response) { return {}; } - if (!response.data.containsKey("actions")) { + var data = response.asMap(); + + if (!data.containsKey("actions")) { return {}; } - var actions = response.data["actions"]; + var actions = response.data["actions"] as Map; - return actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {}; + dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {}; + + return result as Map; } +/* + * Extract a field definition (map) from the provided JSON data. + * + * Notes: + * - If the field is a top-level item, the provided "path" may be a simple string (e.g. "quantity"), + * - If the field is buried in the JSON data, the "path" may use a dotted notation e.g. "items.child.children.quantity" + * + * The map "tree" is traversed based on the provided lookup string, which can use dotted notation. + * This allows complex paths to be used to lookup field information. + */ +Map extractFieldDefinition(Map data, String lookup) { + + List path = lookup.split("."); + + // Shadow copy the data for path traversal + Map _data = data; + + // Iterate through all but the last element of the path + for (int ii = 0; ii < (path.length - 1); ii++) { + + String el = path[ii]; + + if (!_data.containsKey(el)) { + print("Could not find field definition for ${lookup}:"); + print("- Key ${el} missing at index ${ii}"); + return {}; + } + + try { + _data = _data[el] as Map; + } catch (error, stackTrace) { + print("Could not find sub-field element '${el}' for ${lookup}:"); + print(error.toString()); + + // Report the error + sentryReportError(error, stackTrace); + return {}; + } + } + + String el = path.last; + + if (!_data.containsKey(el)) { + print("Could not find field definition for ${lookup}"); + print("- Final field path ${el} missing from data"); + return {}; + } else { + + try { + Map definition = _data[el] as Map; + + return definition; + } catch (error, stacktrace) { + print("Could not find field definition for ${lookup}"); + print(error.toString()); + + // Report the error + sentryReportError(error, stacktrace); + + return {}; + } + + } +} + + /* * Launch an API-driven form, * which uses the OPTIONS metadata (at the provided URL) @@ -524,7 +813,16 @@ Map extractFields(APIResponse response) { * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH) */ -Future launchApiForm(BuildContext context, String title, String url, Map fields, {String fileField = "", Map modelData = const {}, String method = "PATCH", Function(Map)? onSuccess, Function? onCancel}) async { +Future launchApiForm( + BuildContext context, String title, String url, Map fields, + { + String fileField = "", + Map modelData = const {}, + String method = "PATCH", + Function(Map)? onSuccess, + Function? onCancel, + IconData icon = FontAwesomeIcons.save, + }) async { var options = await InvenTreeAPI().options(url); @@ -533,9 +831,10 @@ Future launchApiForm(BuildContext context, String title, String url, Map serverFields = extractFields(options); - if (availableFields.isEmpty) { + if (serverFields.isEmpty) { // User does not have permission to perform this action showSnackIcon( L10().response403, @@ -548,59 +847,35 @@ Future launchApiForm(BuildContext context, String title, String url, Map formFields = []; - // Iterate through the provided fields we wish to display + APIFormField field; + for (String fieldName in fields.keys) { - // Check that the field is actually available at the API endpoint - if (!availableFields.containsKey(fieldName)) { - print("Field '${fieldName}' not available at '${url}'"); + dynamic data = fields[fieldName]; - sentryReportMessage( - "API form called with unknown field '${fieldName}'", - context: { - "url": url.toString(), - } - ); + Map fieldData = {}; + if (data is Map) { + fieldData = Map.from(data); + } + + // Iterate through the provided fields we wish to display + + field = APIFormField(fieldName, fieldData); + + // Extract the definition of this field from the data received from the server + field.definition = extractFieldDefinition(serverFields, field.lookupPath); + + // Skip fields with empty definitions + if (field.definition.isEmpty) { + print("ERROR: Empty field definition for field '${fieldName}'"); continue; } - var remoteField = availableFields[fieldName] ?? {}; - var localField = fields[fieldName] ?? {}; + // Add instance value to the field + field.data["instance_value"] = modelData[fieldName]; - // Override defined field parameters, if provided - for (String key in localField.keys) { - // Special consideration must be taken here! - if (key == "filters") { - - if (!remoteField.containsKey("filters")) { - remoteField["filters"] = {}; - } - - var filters = localField["filters"]; - - if (filters is Map) { - filters.forEach((key, value) { - remoteField["filters"][key] = value; - }); - } - - } else { - remoteField[key] = localField[key]; - } - } - - // Update fields with existing model data - for (String key in modelData.keys) { - - dynamic value = modelData[key]; - - if (availableFields.containsKey(key)) { - availableFields[key]['value'] = value; - } - } - - formFields.add(APIFormField(fieldName, remoteField)); + formFields.add(field); } // Grab existing data for each form field @@ -612,12 +887,13 @@ Future launchApiForm(BuildContext context, String title, String url, Map APIFormWidget( - title, - url, - formFields, - method, - onSuccess: onSuccess, - fileField: fileField, + title, + url, + formFields, + method, + onSuccess: onSuccess, + fileField: fileField, + icon: icon, )) ); } @@ -625,6 +901,19 @@ Future launchApiForm(BuildContext context, String title, String url, Map fields; - Function(Map)? onSuccess; - - APIFormWidget( - this.title, - this.url, - this.fields, - this.method, - { - Key? key, - this.onSuccess, - this.fileField = "", - } - ) : super(key: key); + final Function(Map)? onSuccess; @override - _APIFormWidgetState createState() => _APIFormWidgetState(title, url, fields, method, onSuccess, fileField); + _APIFormWidgetState createState() => _APIFormWidgetState(title, url, fields, method, onSuccess, fileField, icon); } class _APIFormWidgetState extends State { - final _formKey = new GlobalKey(); + _APIFormWidgetState(this.title, this.url, this.fields, this.method, this.onSuccess, this.fileField, this.icon) : super(); - String title; + final _formKey = GlobalKey(); - String url; + final String title; - String method; + final String url; - String fileField; + final String method; + + final String fileField; + + final IconData icon; + + List nonFieldErrors = []; List fields; Function(Map)? onSuccess; - _APIFormWidgetState(this.title, this.url, this.fields, this.method, this.onSuccess, this.fileField) : super(); - bool spacerRequired = false; List _buildForm() { List widgets = []; + // Display non-field errors first + if (nonFieldErrors.isNotEmpty) { + for (String error in nonFieldErrors) { + widgets.add( + ListTile( + title: Text( + error, + style: TextStyle( + color: COLOR_DANGER, + ), + ), + leading: FaIcon( + FontAwesomeIcons.exclamationCircle, + color: COLOR_DANGER + ), + ) + ); + } + + widgets.add(Divider(height: 5)); + + } + for (var field in fields) { if (field.hidden) { @@ -700,7 +1007,7 @@ class _APIFormWidgetState extends State { } } - widgets.add(field.constructField()); + widgets.add(field.constructField(context)); if (field.hasErrors()) { for (String error in field.errorMessages()) { @@ -736,8 +1043,7 @@ class _APIFormWidgetState extends State { return widgets; } - Future _submit(Map data) async { - + Future _submit(Map data) async { // If a file upload is required, we have to handle the submission differently if (fileField.isNotEmpty) { @@ -753,46 +1059,107 @@ class _APIFormWidgetState extends State { if (file != null) { // A valid file has been supplied - return await InvenTreeAPI().uploadFile( + final response = await InvenTreeAPI().uploadFile( url, file, name: fileField, fields: data, ); + + return response; } } } } if (method == "POST") { - return await InvenTreeAPI().post( + final response = await InvenTreeAPI().post( url, body: data, expectedStatusCode: null ); - } else { - return await InvenTreeAPI().patch( - url, - body: data, - expectedStatusCode: null - ); - } + return response; + + } else { + final response = await InvenTreeAPI().patch( + url, + body: data, + expectedStatusCode: null + ); + + return response; + } } + void extractNonFieldErrors(APIResponse response) { + + List errors = []; + + Map data = response.asMap(); + + // Potential keys representing non-field errors + List keys = [ + "__all__", + "non_field_errors", + "errors", + ]; + + for (String key in keys) { + if (data.containsKey(key)) { + dynamic result = data[key]; + + if (result is String) { + errors.add(result); + } else if (result is List) { + for (dynamic element in result) { + errors.add(element.toString()); + } + } + } + } + + nonFieldErrors = errors; + } + + /* + * Submit the form data to the server, and handle the results + */ Future _save(BuildContext context) async { // Package up the form data - Map data = {}; + Map data = {}; + + // Iterate through and find "simple" top-level fields for (var field in fields) { - dynamic value = field.value; - - if (value == null) { - data[field.name] = ""; + if (field.isSimple) { + // Simple top-level field data + data[field.name] = field.data["value"]; } else { - data[field.name] = value.toString(); + // Not so simple... (WHY DID I MAKE THE API SO COMPLEX?) + if (field.parent.isNotEmpty) { + + // TODO: This is a dirty hack, there *must* be a cleaner way?! + + dynamic parent = data[field.parent] ?? {}; + + // In the case of a "nested" object, we need to extract the first item + if (parent is List) { + parent = parent.first; + } + + parent[field.name] = field.data["value"]; + + // Nested fields must be handled as an array! + // For now, we only allow single length nested fields + if (field.nested) { + parent = [parent]; + } + + data[field.parent] = parent; + } } } @@ -833,16 +1200,54 @@ class _APIFormWidgetState extends State { case 400: // Form submission / validation error showSnackIcon( - L10().error, + L10().formError, success: false ); // Update field errors for (var field in fields) { - field.data['errors'] = response.data[field.name]; + field.extractErrorMessages(response); } + + extractNonFieldErrors(response); + + break; + case 401: + showSnackIcon( + "401: " + L10().response401, + success: false + ); + break; + case 403: + showSnackIcon( + "403: " + L10().response403, + success: false, + ); + break; + case 404: + showSnackIcon( + "404: " + L10().response404, + success: false, + ); + break; + case 405: + showSnackIcon( + "405: " + L10().response405, + success: false, + ); + break; + case 500: + showSnackIcon( + "500: " + L10().response500, + success: false, + ); + break; + default: + showSnackIcon( + "${response.statusCode}: " + L10().responseInvalid, + success: false, + ); break; - // TODO: Other status codes? } setState(() { @@ -859,7 +1264,7 @@ class _APIFormWidgetState extends State { title: Text(title), actions: [ IconButton( - icon: FaIcon(FontAwesomeIcons.save), + icon: FaIcon(icon), onPressed: () { if (_formKey.currentState!.validate()) { diff --git a/lib/app_colors.dart b/lib/app_colors.dart index 36b7ee01..99d81384 100644 --- a/lib/app_colors.dart +++ b/lib/app_colors.dart @@ -1,6 +1,6 @@ -import 'dart:ui'; +import "dart:ui"; const Color COLOR_GRAY = Color.fromRGBO(50, 50, 50, 1); const Color COLOR_GRAY_LIGHT = Color.fromRGBO(150, 150, 150, 1); diff --git a/lib/app_settings.dart b/lib/app_settings.dart index 7146b91b..f47b41d3 100644 --- a/lib/app_settings.dart +++ b/lib/app_settings.dart @@ -2,14 +2,20 @@ * Class for managing app-level configuration options */ -import 'package:sembast/sembast.dart'; -import 'package:inventree/preferences.dart'; +import "package:sembast/sembast.dart"; +import "package:inventree/preferences.dart"; class InvenTreeSettingsManager { + factory InvenTreeSettingsManager() { + return _manager; + } + + InvenTreeSettingsManager._internal(); + final store = StoreRef("settings"); - Future get _db async => await InvenTreePreferencesDB.instance.database; + Future get _db async => InvenTreePreferencesDB.instance.database; Future getValue(String key, dynamic backup) async { @@ -22,17 +28,22 @@ class InvenTreeSettingsManager { return value; } + // Load a boolean setting + Future getBool(String key, bool backup) async { + final dynamic value = await getValue(key, backup); + + if (value is bool) { + return value; + } else { + return backup; + } + } + Future setValue(String key, dynamic value) async { await store.record(key).put(await _db, value); } // Ensure we only ever create a single instance of this class - static final InvenTreeSettingsManager _manager = new InvenTreeSettingsManager._internal(); - - factory InvenTreeSettingsManager() { - return _manager; - } - - InvenTreeSettingsManager._internal(); -} \ No newline at end of file + static final InvenTreeSettingsManager _manager = InvenTreeSettingsManager._internal(); +} diff --git a/lib/barcode.dart b/lib/barcode.dart index de3af32b..aeeba495 100644 --- a/lib/barcode.dart +++ b/lib/barcode.dart @@ -1,26 +1,24 @@ -import 'package:inventree/app_settings.dart'; -import 'package:inventree/inventree/sentry.dart'; -import 'package:inventree/widget/dialogs.dart'; -import 'package:inventree/widget/snacks.dart'; -import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:one_context/one_context.dart'; +import "dart:io"; -import 'package:qr_code_scanner/qr_code_scanner.dart'; +import "package:inventree/inventree/sentry.dart"; +import "package:inventree/widget/dialogs.dart"; +import "package:inventree/widget/snacks.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:one_context/one_context.dart"; -import 'package:inventree/inventree/stock.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/l10.dart'; +import "package:qr_code_scanner/qr_code_scanner.dart"; -import 'package:inventree/api.dart'; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/helpers.dart"; +import "package:inventree/api.dart"; -import 'package:inventree/widget/location_display.dart'; -import 'package:inventree/widget/part_detail.dart'; -import 'package:inventree/widget/stock_detail.dart'; - -import 'dart:io'; +import "package:inventree/widget/location_display.dart"; +import "package:inventree/widget/part_detail.dart"; +import "package:inventree/widget/stock_detail.dart"; class BarcodeHandler { @@ -32,31 +30,11 @@ class BarcodeHandler { * based on the response returned from the InvenTree server */ - String getOverlayText(BuildContext context) => "Barcode Overlay"; + BarcodeHandler(); - BarcodeHandler(); + String getOverlayText(BuildContext context) => "Barcode Overlay"; - QRViewController? _controller; - - void successTone() async { - - final bool en = await InvenTreeSettingsManager().getValue("barcodeSounds", true) as bool; - - if (en) { - final player = AudioCache(); - player.play("sounds/barcode_scan.mp3"); - } - } - - void failureTone() async { - - final bool en = await InvenTreeSettingsManager().getValue("barcodeSounds", true) as bool; - - if (en) { - final player = AudioCache(); - player.play("sounds/barcode_error.mp3"); - } - } + QRViewController? _controller; Future onBarcodeMatched(BuildContext context, Map data) async { // Called when the server "matches" a barcode @@ -101,8 +79,10 @@ class BarcodeHandler { _controller?.resumeCamera(); + Map data = response.asMap(); + // Handle strange response from the server - if (!response.isValid() || response.data == null || !(response.data is Map)) { + if (!response.isValid() || !response.isMap()) { onBarcodeUnknown(context, {}); // We want to know about this one! @@ -118,12 +98,12 @@ class BarcodeHandler { "errorDetail": response.errorDetail, } ); - } else if (response.data.containsKey('error')) { - onBarcodeUnknown(context, response.data); - } else if (response.data.containsKey('success')) { - onBarcodeMatched(context, response.data); + } else if (data.containsKey("error")) { + onBarcodeUnknown(context, data); + } else if (data.containsKey("success")) { + onBarcodeMatched(context, data); } else { - onBarcodeUnhandled(context, response.data); + onBarcodeUnhandled(context, data); } } } @@ -156,9 +136,9 @@ class BarcodeScanHandler extends BarcodeHandler { int pk = -1; // A stocklocation has been passed? - if (data.containsKey('stocklocation')) { + if (data.containsKey("stocklocation")) { - pk = (data['stocklocation']?['pk'] ?? -1) as int; + pk = (data["stocklocation"]?["pk"] ?? -1) as int; if (pk > 0) { @@ -180,9 +160,9 @@ class BarcodeScanHandler extends BarcodeHandler { ); } - } else if (data.containsKey('stockitem')) { + } else if (data.containsKey("stockitem")) { - pk = (data['stockitem']?['pk'] ?? -1) as int; + pk = (data["stockitem"]?["pk"] ?? -1) as int; if (pk > 0) { @@ -206,9 +186,9 @@ class BarcodeScanHandler extends BarcodeHandler { success: false ); } - } else if (data.containsKey('part')) { + } else if (data.containsKey("part")) { - pk = (data['part']?['pk'] ?? -1) as int; + pk = (data["part"]?["pk"] ?? -1) as int; if (pk > 0) { @@ -258,93 +238,24 @@ class BarcodeScanHandler extends BarcodeHandler { } } - -class StockItemBarcodeAssignmentHandler extends BarcodeHandler { - /* - * Barcode handler for assigning a new barcode to a stock item - */ - - final InvenTreeStockItem item; - - StockItemBarcodeAssignmentHandler(this.item); - - @override - String getOverlayText(BuildContext context) => L10().barcodeScanAssign; - - @override - Future onBarcodeMatched(BuildContext context, Map data) async { - - failureTone(); - - // If the barcode is known, we can't assign it to the stock item! - showSnackIcon( - L10().barcodeInUse, - icon: FontAwesomeIcons.qrcode, - success: false - ); - } - - @override - Future onBarcodeUnknown(BuildContext context, Map data) async { - // If the barcode is unknown, we *can* assign it to the stock item! - - if (!data.containsKey("hash")) { - showServerError( - L10().missingData, - L10().barcodeMissingHash, - ); - } else { - - // Send the 'hash' code as the UID for the stock item - item.update( - values: { - "uid": data['hash'], - } - ).then((result) { - if (result) { - - failureTone(); - - Navigator.of(context).pop(); - - showSnackIcon( - L10().barcodeAssigned, - success: true, - icon: FontAwesomeIcons.qrcode - ); - } else { - - successTone(); - - showSnackIcon( - L10().barcodeNotAssigned, - success: false, - icon: FontAwesomeIcons.qrcode - ); - } - }); - } - } -} - class StockItemScanIntoLocationHandler extends BarcodeHandler { /* * Barcode handler for scanning a provided StockItem into a scanned StockLocation */ - final InvenTreeStockItem item; - StockItemScanIntoLocationHandler(this.item); + final InvenTreeStockItem item; + @override String getOverlayText(BuildContext context) => L10().barcodeScanLocation; @override Future onBarcodeMatched(BuildContext context, Map data) async { - // If the barcode points to a 'stocklocation', great! - if (data.containsKey('stocklocation')) { + // If the barcode points to a "stocklocation", great! + if (data.containsKey("stocklocation")) { // Extract location information - int location = (data['stocklocation']['pk'] ?? -1) as int; + int location = (data["stocklocation"]["pk"] ?? -1) as int; if (location == -1) { showSnackIcon( @@ -394,11 +305,11 @@ class StockLocationScanInItemsHandler extends BarcodeHandler { /* * Barcode handler for scanning stock item(s) into the specified StockLocation */ - - final InvenTreeStockLocation location; - + StockLocationScanInItemsHandler(this.location); - + + final InvenTreeStockLocation location; + @override String getOverlayText(BuildContext context) => L10().barcodeScanItem; @@ -406,11 +317,11 @@ class StockLocationScanInItemsHandler extends BarcodeHandler { Future onBarcodeMatched(BuildContext context, Map data) async { // Returned barcode must match a stock item - if (data.containsKey('stockitem')) { + if (data.containsKey("stockitem")) { - int item_id = data['stockitem']['pk'] as int; + int item_id = data["stockitem"]["pk"] as int; - final InvenTreeStockItem? item = await InvenTreeStockItem().get(item_id) as InvenTreeStockItem; + final InvenTreeStockItem? item = await InvenTreeStockItem().get(item_id) as InvenTreeStockItem?; if (item == null) { @@ -462,11 +373,78 @@ class StockLocationScanInItemsHandler extends BarcodeHandler { } +class UniqueBarcodeHandler extends BarcodeHandler { + /* + * Barcode handler for finding a "unique" barcode (one that does not match an item in the database) + */ + + UniqueBarcodeHandler(this.callback, {this.overlayText = ""}); + + // Callback function when a "unique" barcode hash is found + final Function(String) callback; + + final String overlayText; + + @override + String getOverlayText(BuildContext context) { + if (overlayText.isEmpty) { + return L10().barcodeScanAssign; + } else { + return overlayText; + } + } + + @override + Future onBarcodeMatched(BuildContext context, Map data) async { + + failureTone(); + + // If the barcode is known, we can"t assign it to the stock item! + showSnackIcon( + L10().barcodeInUse, + icon: FontAwesomeIcons.qrcode, + success: false + ); + } + + @override + Future onBarcodeUnknown(BuildContext context, Map data) async { + // If the barcode is unknown, we *can* assign it to the stock item! + + if (!data.containsKey("hash")) { + showServerError( + L10().missingData, + L10().barcodeMissingHash, + ); + } else { + String hash = (data["hash"] ?? "") as String; + + if (hash.isEmpty) { + failureTone(); + + showSnackIcon( + L10().barcodeError, + success: false, + ); + } else { + + successTone(); + + // Close the barcode scanner + Navigator.of(context).pop(); + + callback(hash); + } + } + } +} + + class InvenTreeQRView extends StatefulWidget { - final BarcodeHandler _handler; + const InvenTreeQRView(this._handler, {Key? key}) : super(key: key); - InvenTreeQRView(this._handler, {Key? key}) : super(key: key); + final BarcodeHandler _handler; @override State createState() => _QRViewState(_handler); @@ -475,7 +453,9 @@ class InvenTreeQRView extends StatefulWidget { class _QRViewState extends State { - final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); + _QRViewState(this._handler) : super(); + + final GlobalKey qrKey = GlobalKey(debugLabel: "QR"); QRViewController? _controller; @@ -494,8 +474,6 @@ class _QRViewState extends State { _controller!.resumeCamera(); } - _QRViewState(this._handler) : super(); - void _onViewCreated(BuildContext context, QRViewController controller) { _controller = controller; controller.scannedDataStream.listen((barcode) { diff --git a/lib/dummy_dsn.dart b/lib/dummy_dsn.dart new file mode 100644 index 00000000..d53bb6ff --- /dev/null +++ b/lib/dummy_dsn.dart @@ -0,0 +1,3 @@ +// Dummy DSN to use for unit testing, etc + +const String SENTRY_DSN_KEY = "https://12345678901234567890@abcdef.ingest.sentry.io/11223344"; \ No newline at end of file diff --git a/lib/generated/i18n.dart b/lib/generated/i18n.dart index db983ef0..09177b85 100644 --- a/lib/generated/i18n.dart +++ b/lib/generated/i18n.dart @@ -12,11 +12,9 @@ import 'package:flutter/material.dart'; class S implements WidgetsLocalizations { const S(); - static const GeneratedLocalizationsDelegate delegate = - const GeneratedLocalizationsDelegate(); + static const GeneratedLocalizationsDelegate delegate = GeneratedLocalizationsDelegate(); - static S of(BuildContext context) => - Localizations.of(context, WidgetsLocalizations); + static S of(BuildContext context) => Localizations.of(context, WidgetsLocalizations); @override TextDirection get textDirection => TextDirection.ltr; diff --git a/lib/helpers.dart b/lib/helpers.dart new file mode 100644 index 00000000..8b3e1764 --- /dev/null +++ b/lib/helpers.dart @@ -0,0 +1,37 @@ +/* + * A set of helper functions to reduce boilerplate code + */ + +/* + * Simplify a numerical value into a string, + * supressing trailing zeroes + */ + +import "package:audioplayers/audioplayers.dart"; +import "package:inventree/app_settings.dart"; + +String simpleNumberString(double number) { + // Ref: https://stackoverflow.com/questions/55152175/how-to-remove-trailing-zeros-using-dart + + return number.toStringAsFixed(number.truncateToDouble() == number ? 0 : 1); +} + +Future successTone() async { + + final bool en = await InvenTreeSettingsManager().getValue("barcodeSounds", true) as bool; + + if (en) { + final player = AudioCache(); + player.play("sounds/barcode_scan.mp3"); + } +} + +Future failureTone() async { + + final bool en = await InvenTreeSettingsManager().getValue("barcodeSounds", true) as bool; + + if (en) { + final player = AudioCache(); + player.play("sounds/barcode_error.mp3"); + } +} \ No newline at end of file diff --git a/lib/inventree/company.dart b/lib/inventree/company.dart index da34f14a..2dec8922 100644 --- a/lib/inventree/company.dart +++ b/lib/inventree/company.dart @@ -1,6 +1,8 @@ -import 'package:inventree/api.dart'; +import "dart:async"; -import 'model.dart'; +import "package:inventree/api.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/purchase_order.dart"; /* @@ -9,6 +11,10 @@ import 'model.dart'; class InvenTreeCompany extends InvenTreeModel { + InvenTreeCompany() : super(); + + InvenTreeCompany.fromJson(Map json) : super.fromJson(json); + @override String get URL => "company/"; @@ -25,25 +31,51 @@ class InvenTreeCompany extends InvenTreeModel { }; } - InvenTreeCompany() : super(); + String get image => (jsondata["image"] ?? jsondata["thumbnail"] ?? InvenTreeAPI.staticImage) as String; - String get image => jsondata['image'] ?? jsondata['thumbnail'] ?? InvenTreeAPI.staticImage; + String get thumbnail => (jsondata["thumbnail"] ?? jsondata["image"] ?? InvenTreeAPI.staticThumb) as String; - String get thumbnail => jsondata['thumbnail'] ?? jsondata['image'] ?? InvenTreeAPI.staticThumb; + String get website => (jsondata["website"] ?? "") as String; - String get website => jsondata['website'] ?? ''; + String get phone => (jsondata["phone"] ?? "") as String; - String get phone => jsondata['phone'] ?? ''; + String get email => (jsondata["email"] ?? "") as String; - String get email => jsondata['email'] ?? ''; + bool get isSupplier => (jsondata["is_supplier"] ?? false) as bool; - bool get isSupplier => jsondata['is_supplier'] ?? false; + bool get isManufacturer => (jsondata["is_manufacturer"] ?? false) as bool; - bool get isManufacturer => jsondata['is_manufacturer'] ?? false; + bool get isCustomer => (jsondata["is_customer"] ?? false) as bool; - bool get isCustomer => jsondata['is_customer'] ?? false; + int get partSuppliedCount => (jsondata["parts_supplied"] ?? 0) as int; - InvenTreeCompany.fromJson(Map json) : super.fromJson(json); + int get partManufacturedCount => (jsondata["parts_manufactured"] ?? 0) as int; + + // Request a list of purchase orders against this company + Future> getPurchaseOrders({bool? outstanding}) async { + + Map filters = { + "supplier": "${pk}" + }; + + if (outstanding != null) { + filters["outstanding"] = outstanding ? "true" : "false"; + } + + final List results = await InvenTreePurchaseOrder().list( + filters: filters + ); + + List orders = []; + + for (InvenTreeModel model in results) { + if (model is InvenTreePurchaseOrder) { + orders.add(model); + } + } + + return orders; + } @override InvenTreeModel createFromJson(Map json) { @@ -58,6 +90,11 @@ class InvenTreeCompany extends InvenTreeModel { * The InvenTreeSupplierPart class represents the SupplierPart model in the InvenTree database */ class InvenTreeSupplierPart extends InvenTreeModel { + + InvenTreeSupplierPart() : super(); + + InvenTreeSupplierPart.fromJson(Map json) : super.fromJson(json); + @override String get URL => "company/part/"; @@ -79,27 +116,29 @@ class InvenTreeSupplierPart extends InvenTreeModel { return _filters(); } - InvenTreeSupplierPart() : super(); + int get manufacturerId => (jsondata["manufacturer"] ?? -1) as int; - InvenTreeSupplierPart.fromJson(Map json) : super.fromJson(json); + String get manufacturerName => (jsondata["manufacturer_detail"]["name"] ?? "") as String; - int get manufacturerId => (jsondata['manufacturer'] ?? -1) as int; + String get manufacturerImage => (jsondata["manufacturer_detail"]["image"] ?? jsondata["manufacturer_detail"]["thumbnail"] ?? InvenTreeAPI.staticThumb) as String; - String get manufacturerName => jsondata['manufacturer_detail']['name']; + int get manufacturerPartId => (jsondata["manufacturer_part"] ?? -1) as int; - String get manufacturerImage => jsondata['manufacturer_detail']['image'] ?? jsondata['manufacturer_detail']['thumbnail']; + int get supplierId => (jsondata["supplier"] ?? -1) as int; - int get manufacturerPartId => (jsondata['manufacturer_part'] ?? -1) as int; + String get supplierName => (jsondata["supplier_detail"]["name"] ?? "") as String; - int get supplierId => (jsondata['supplier'] ?? -1) as int; + String get supplierImage => (jsondata["supplier_detail"]["image"] ?? jsondata["supplier_detail"]["thumbnail"] ?? InvenTreeAPI.staticThumb) as String; - String get supplierName => jsondata['supplier_detail']['name']; + String get SKU => (jsondata["SKU"] ?? "") as String; - String get supplierImage => jsondata['supplier_detail']['image'] ?? jsondata['supplier_detail']['thumbnail']; + String get MPN => (jsondata["MPN"] ?? "") as String; - String get SKU => (jsondata['SKU'] ?? '') as String; + int get partId => (jsondata["part"] ?? -1) as int; - String get MPN => jsondata['MPN'] ?? ''; + String get partImage => (jsondata["part_detail"]["thumbnail"] ?? InvenTreeAPI.staticThumb) as String; + + String get partName => (jsondata["part_detail"]["full_name"] ?? "") as String; @override InvenTreeModel createFromJson(Map json) { @@ -112,6 +151,10 @@ class InvenTreeSupplierPart extends InvenTreeModel { class InvenTreeManufacturerPart extends InvenTreeModel { + InvenTreeManufacturerPart() : super(); + + InvenTreeManufacturerPart.fromJson(Map json) : super.fromJson(json); + @override String url = "company/part/manufacturer/"; @@ -122,15 +165,11 @@ class InvenTreeManufacturerPart extends InvenTreeModel { }; } - InvenTreeManufacturerPart() : super(); + int get partId => (jsondata["part"] ?? -1) as int; - InvenTreeManufacturerPart.fromJson(Map json) : super.fromJson(json); + int get manufacturerId => (jsondata["manufacturer"] ?? -1) as int; - int get partId => (jsondata['part'] ?? -1) as int; - - int get manufacturerId => (jsondata['manufacturer'] ?? -1) as int; - - String get MPN => (jsondata['MPN'] ?? '') as String; + String get MPN => (jsondata["MPN"] ?? "") as String; @override InvenTreeModel createFromJson(Map json) { diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 50ec330f..d894ae68 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -1,18 +1,17 @@ -import 'dart:async'; -import 'dart:io'; +import "dart:async"; +import "dart:io"; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/api.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:inventree/inventree/sentry.dart'; -import 'package:inventree/widget/dialogs.dart'; -import 'package:url_launcher/url_launcher.dart'; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/api.dart"; +import "package:flutter/cupertino.dart"; +import "package:inventree/inventree/sentry.dart"; +import "package:inventree/widget/dialogs.dart"; +import "package:url_launcher/url_launcher.dart"; -import 'package:path/path.dart' as path; -import 'package:http/http.dart' as http; +import "package:path/path.dart" as path; -import '../l10.dart'; -import '../api_form.dart'; +import "package:inventree/l10.dart"; +import "package:inventree/api_form.dart"; // Paginated response object @@ -40,12 +39,17 @@ class InvenTreePageResponse { */ class InvenTreeModel { + InvenTreeModel(); + + // Construct an InvenTreeModel from a JSON data object + InvenTreeModel.fromJson(this.jsondata); + // Override the endpoint URL for each subclass String get URL => ""; // Override the web URL for each subclass // Note: If the WEB_URL is the same (except for /api/) as URL then just leave blank - String WEB_URL = ""; + String get WEB_URL => ""; String get webUrl { @@ -114,36 +118,23 @@ class InvenTreeModel { Map jsondata = {}; // Accessor for the API - var api = InvenTreeAPI(); + InvenTreeAPI get api => InvenTreeAPI(); - // Default empty object constructor - InvenTreeModel() { - jsondata.clear(); - } - - // Construct an InvenTreeModel from a JSON data object - InvenTreeModel.fromJson(Map json) { - - // Store the json object - jsondata = json; - - } - - int get pk => (jsondata['pk'] ?? -1) as int; + int get pk => (jsondata["pk"] ?? -1) as int; // Some common accessors - String get name => jsondata['name'] ?? ''; + String get name => (jsondata["name"] ?? "") as String; - String get description => jsondata['description'] ?? ''; + String get description => (jsondata["description"] ?? "") as String; - String get notes => jsondata['notes'] ?? ''; + String get notes => (jsondata["notes"] ?? "") as String; - int get parentId => (jsondata['parent'] ?? -1) as int; + int get parentId => (jsondata["parent"] ?? -1) as int; // Legacy API provided external link as "URL", while newer API uses "link" - String get link => jsondata['link'] ?? jsondata['URL'] ?? ''; + String get link => (jsondata["link"] ?? jsondata["URL"] ?? "") as String; - void goToInvenTreePage() async { + Future goToInvenTreePage() async { if (await canLaunch(webUrl)) { await launch(webUrl); @@ -152,7 +143,7 @@ class InvenTreeModel { } } - void openLink() async { + Future openLink() async { if (link.isNotEmpty) { @@ -162,7 +153,7 @@ class InvenTreeModel { } } - String get keywords => jsondata['keywords'] ?? ''; + String get keywords => (jsondata["keywords"] ?? "") as String; // Create a new object from JSON data (not a constructor!) InvenTreeModel createFromJson(Map json) { @@ -176,20 +167,60 @@ class InvenTreeModel { String get url => "${URL}/${pk}/".replaceAll("//", "/"); // Search this Model type in the database - Future> search(BuildContext context, String searchTerm, {Map filters = const {}}) async { + Future> search(String searchTerm, {Map filters = const {}, int offset = 0, int limit = 25}) async { - filters["search"] = searchTerm; + Map searchFilters = {}; - final results = list(filters: filters); + for (String key in filters.keys) { + searchFilters[key] = filters[key] ?? ""; + } + + searchFilters["search"] = searchTerm; + searchFilters["offset"] = "${offset}"; + searchFilters["limit"] = "${limit}"; + + final results = list(filters: searchFilters); return results; } - Map defaultListFilters() { return Map(); } + // Return the number of results that would meet a particular "query" + Future count({Map filters = const {}, String searchQuery = ""} ) async { + + var params = defaultListFilters(); + + filters.forEach((String key, String value) { + params[key] = value; + }); + + if (searchQuery.isNotEmpty) { + params["search"] = searchQuery; + } + + // Limit to 1 result, for quick DB access + params["limit"] = "1"; + + var response = await api.get(URL, params: params); + + if (response.isValid()) { + int n = int.tryParse(response.data["count"].toString()) ?? 0; + + print("${URL} -> ${n} results"); + return n; + } else { + return 0; + } +} + + Map defaultListFilters() { + return {}; + } // A map of "default" headers to use when performing a GET request - Map defaultGetFilters() { return Map(); } + Map defaultGetFilters() { + return {}; + } /* * Reload this object, by requesting data from the server @@ -198,7 +229,7 @@ class InvenTreeModel { var response = await api.get(url, params: defaultGetFilters(), expectedStatusCode: 200); - if (!response.isValid() || response.data == null || !(response.data is Map)) { + if (!response.isValid() || response.data == null || (response.data is! Map)) { // Report error if (response.statusCode > 0) { @@ -224,7 +255,7 @@ class InvenTreeModel { } - jsondata = response.data; + jsondata = response.asMap(); return true; } @@ -267,12 +298,12 @@ class InvenTreeModel { // Override any default values for (String key in filters.keys) { - params[key] = filters[key] ?? ''; + params[key] = filters[key] ?? ""; } var response = await api.get(url, params: params); - if (!response.isValid() || response.data == null || !(response.data is Map)) { + if (!response.isValid() || response.data == null || response.data is! Map) { if (response.statusCode > 0) { await sentryReportMessage( @@ -297,25 +328,23 @@ class InvenTreeModel { } - return createFromJson(response.data); + return createFromJson(response.asMap()); } Future create(Map data) async { - print("CREATE: ${URL} ${data.toString()}"); - - if (data.containsKey('pk')) { - data.remove('pk'); + if (data.containsKey("pk")) { + data.remove("pk"); } - if (data.containsKey('id')) { - data.remove('id'); + if (data.containsKey("id")) { + data.remove("id"); } var response = await api.post(URL, body: data); // Invalid response returned from server - if (!response.isValid() || response.data == null || !(response.data is Map)) { + if (!response.isValid() || response.data == null || response.data is! Map) { if (response.statusCode > 0) { await sentryReportMessage( @@ -340,19 +369,34 @@ class InvenTreeModel { return null; } - return createFromJson(response.data); + return createFromJson(response.asMap()); } Future listPaginated(int limit, int offset, {Map filters = const {}}) async { var params = defaultListFilters(); for (String key in filters.keys) { - params[key] = filters[key] ?? ''; + params[key] = filters[key] ?? ""; } params["limit"] = "${limit}"; params["offset"] = "${offset}"; + /* Special case: "original_search": + * - We may wish to provide an original "query" which is augmented by the user + * - Thus, "search" and "original_search" may both be provided + * - In such a case, we want to concatenate them together + */ + if (params.containsKey("original_search")) { + + String search = params["search"] ?? ""; + String original = params["original_search"] ?? ""; + + params["search"] = "${search} ${original}".trim(); + + params.remove("original_search"); + } + var response = await api.get(URL, params: params); if (!response.isValid()) { @@ -360,15 +404,17 @@ class InvenTreeModel { } // Construct the response - InvenTreePageResponse page = new InvenTreePageResponse(); + InvenTreePageResponse page = InvenTreePageResponse(); - if (response.data.containsKey("count") && response.data.containsKey("results")) { - page.count = response.data["count"] as int; + var data = response.asMap(); + + if (data.containsKey("count") && data.containsKey("results")) { + page.count = (data["count"] ?? 0) as int; page.results = []; for (var result in response.data["results"]) { - page.addResult(createFromJson(result)); + page.addResult(createFromJson(result as Map)); } return page; @@ -384,7 +430,7 @@ class InvenTreeModel { var params = defaultListFilters(); for (String key in filters.keys) { - params[key] = filters[key] ?? ''; + params[key] = filters[key] ?? ""; } var response = await api.get(URL, params: params); @@ -396,20 +442,22 @@ class InvenTreeModel { return results; } - dynamic data; + List data = []; - if (response.data is List) { - data = response.data; - } else if (response.data.containsKey('results')) { - data = response.data['results']; - } else { - data = []; + if (response.isList()) { + data = response.asList(); + } else if (response.isMap()) { + var mData = response.asMap(); + + if (mData.containsKey("results")) { + data = (response.data["results"] ?? []) as List; + } } for (var d in data) { // Create a new object (of the current class type - InvenTreeModel obj = createFromJson(d); + InvenTreeModel obj = createFromJson(d as Map); results.add(obj); } @@ -421,9 +469,9 @@ class InvenTreeModel { // Provide a listing of objects at the endpoint // TODO - Static function which returns a list of objects (of this class) - // TODO - Define a 'delete' function + // TODO - Define a "delete" function - // TODO - Define a 'save' / 'update' function + // TODO - Define a "save" / "update" function // Override this function for each sub-class bool matchAgainstString(String filter) { @@ -457,10 +505,11 @@ class InvenTreeModel { class InvenTreeAttachment extends InvenTreeModel { // Class representing an "attachment" file - InvenTreeAttachment() : super(); - String get attachment => jsondata["attachment"] ?? ''; + InvenTreeAttachment.fromJson(Map json) : super.fromJson(json); + + String get attachment => (jsondata["attachment"] ?? "") as String; // Return the filename of the attachment String get filename { @@ -498,25 +547,23 @@ class InvenTreeAttachment extends InvenTreeModel { return FontAwesomeIcons.fileAlt; } - String get comment => jsondata["comment"] ?? ''; + String get comment => (jsondata["comment"] ?? "") as String; DateTime? get uploadDate { if (jsondata.containsKey("upload_date")) { - return DateTime.tryParse(jsondata["upload_date"] ?? ''); + return DateTime.tryParse((jsondata["upload_date"] ?? "") as String); } else { return null; } } - InvenTreeAttachment.fromJson(Map json) : super.fromJson(json); - Future uploadAttachment(File attachment, {String comment = "", Map fields = const {}}) async { final APIResponse response = await InvenTreeAPI().uploadFile( URL, attachment, - method: 'POST', - name: 'attachment', + method: "POST", + name: "attachment", fields: fields ); diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index 13f7911a..e327cb9c 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -1,15 +1,19 @@ -import 'package:inventree/api.dart'; -import 'package:inventree/inventree/stock.dart'; -import 'package:inventree/inventree/company.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:inventree/l10.dart'; +import "dart:io"; -import 'model.dart'; -import 'dart:io'; -import 'package:http/http.dart' as http; +import "package:inventree/api.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/inventree/company.dart"; +import "package:flutter/cupertino.dart"; +import "package:inventree/l10.dart"; + +import "model.dart"; class InvenTreePartCategory extends InvenTreeModel { + InvenTreePartCategory() : super(); + + InvenTreePartCategory.fromJson(Map json) : super.fromJson(json); + @override String get URL => "part/category/"; @@ -25,21 +29,20 @@ class InvenTreePartCategory extends InvenTreeModel { @override Map defaultListFilters() { - var filters = new Map(); - filters["active"] = "true"; - filters["cascade"] = "false"; - - return filters; + return { + "active": "true", + "cascade": "false" + }; } - String get pathstring => jsondata['pathstring'] ?? ''; + String get pathstring => (jsondata["pathstring"] ?? "") as String; String get parentpathstring { // TODO - Drive the refactor tractor through this List psplit = pathstring.split("/"); - if (psplit.length > 0) { + if (psplit.isNotEmpty) { psplit.removeLast(); } @@ -52,11 +55,7 @@ class InvenTreePartCategory extends InvenTreeModel { return p; } - int get partcount => jsondata['parts'] ?? 0; - - InvenTreePartCategory() : super(); - - InvenTreePartCategory.fromJson(Map json) : super.fromJson(json); + int get partcount => (jsondata["parts"] ?? 0) as int; @override InvenTreeModel createFromJson(Map json) { @@ -71,25 +70,23 @@ class InvenTreePartCategory extends InvenTreeModel { class InvenTreePartTestTemplate extends InvenTreeModel { - @override - String get URL => "part/test-template/"; - - String get key => jsondata['key'] ?? ''; - - String get testName => jsondata['test_name'] ?? ''; - - String get description => jsondata['description'] ?? ''; - - bool get required => jsondata['required'] ?? false; - - bool get requiresValue => jsondata['requires_value'] ?? false; - - bool get requiresAttachment => jsondata['requires_attachment'] ?? false; - InvenTreePartTestTemplate() : super(); InvenTreePartTestTemplate.fromJson(Map json) : super.fromJson(json); + @override + String get URL => "part/test-template/"; + + String get key => (jsondata["key"] ?? "") as String; + + String get testName => (jsondata["test_name"] ?? "") as String; + + bool get required => (jsondata["required"] ?? false) as bool; + + bool get requiresValue => (jsondata["requires_value"] ?? false) as bool; + + bool get requiresAttachment => (jsondata["requires_attachment"] ?? false) as bool; + @override InvenTreeModel createFromJson(Map json) { var template = InvenTreePartTestTemplate.fromJson(json); @@ -125,6 +122,10 @@ class InvenTreePartTestTemplate extends InvenTreeModel { class InvenTreePart extends InvenTreeModel { + InvenTreePart() : super(); + + InvenTreePart.fromJson(Map json) : super.fromJson(json); + @override String get URL => "part/"; @@ -138,9 +139,9 @@ class InvenTreePart extends InvenTreeModel { "keywords": {}, "link": {}, - // Parent category - "category": { - }, + "category": {}, + + "default_location": {}, "units": {}, @@ -195,7 +196,7 @@ class InvenTreePart extends InvenTreeModel { }); } - int get supplierCount => (jsondata['suppliers'] ?? 0) as int; + int get supplierCount => (jsondata["suppliers"] ?? 0) as int; // Request supplier parts for this part Future> getSupplierParts() async { @@ -241,8 +242,10 @@ class InvenTreePart extends InvenTreeModel { }); } + int? get defaultLocation => jsondata["default_location"] as int?; + // Get the number of stock on order for this Part - double get onOrder => double.tryParse(jsondata['ordering'].toString()) ?? 0; + double get onOrder => double.tryParse(jsondata["ordering"].toString()) ?? 0; String get onOrderString { @@ -254,7 +257,7 @@ class InvenTreePart extends InvenTreeModel { } // Get the stock count for this Part - double get inStock => double.tryParse(jsondata['in_stock'].toString()) ?? 0; + double get inStock => double.tryParse(jsondata["in_stock"].toString()) ?? 0; String get inStockString { @@ -271,69 +274,69 @@ class InvenTreePart extends InvenTreeModel { return q; } - String get units => jsondata["units"] ?? ""; + String get units => (jsondata["units"] ?? "") as String; // Get the number of units being build for this Part - double get building => double.tryParse(jsondata['building'].toString()) ?? 0; + double get building => double.tryParse(jsondata["building"].toString()) ?? 0; // Get the number of BOM items in this Part (if it is an assembly) - int get bomItemCount => (jsondata['bom_items'] ?? 0) as int; + int get bomItemCount => (jsondata["bom_items"] ?? 0) as int; // Get the number of BOMs this Part is used in (if it is a component) - int get usedInCount => (jsondata['used_in'] ?? 0) as int; + int get usedInCount => (jsondata["used_in"] ?? 0) as int; - bool get isAssembly => (jsondata['assembly'] ?? false) as bool; + bool get isAssembly => (jsondata["assembly"] ?? false) as bool; - bool get isComponent => (jsondata['component'] ?? false) as bool; + bool get isComponent => (jsondata["component"] ?? false) as bool; - bool get isPurchaseable => (jsondata['purchaseable'] ?? false) as bool; + bool get isPurchaseable => (jsondata["purchaseable"] ?? false) as bool; - bool get isSalable => (jsondata['salable'] ?? false) as bool; + bool get isSalable => (jsondata["salable"] ?? false) as bool; - bool get isActive => (jsondata['active'] ?? false) as bool; + bool get isActive => (jsondata["active"] ?? false) as bool; - bool get isVirtual => (jsondata['virtual'] ?? false) as bool; + bool get isVirtual => (jsondata["virtual"] ?? false) as bool; - bool get isTrackable => (jsondata['trackable'] ?? false) as bool; + bool get isTrackable => (jsondata["trackable"] ?? false) as bool; // Get the IPN (internal part number) for the Part instance - String get IPN => jsondata['IPN'] ?? ''; + String get IPN => (jsondata["IPN"] ?? "") as String; // Get the revision string for the Part instance - String get revision => jsondata['revision'] ?? ''; + String get revision => (jsondata["revision"] ?? "") as String; - // Get the category ID for the Part instance (or 'null' if does not exist) - int get categoryId => (jsondata['category'] ?? -1) as int; + // Get the category ID for the Part instance (or "null" if does not exist) + int get categoryId => (jsondata["category"] ?? -1) as int; // Get the category name for the Part instance String get categoryName { // Inavlid category ID - if (categoryId <= 0) return ''; + if (categoryId <= 0) return ""; - if (!jsondata.containsKey('category_detail')) return ''; + if (!jsondata.containsKey("category_detail")) return ""; - return jsondata['category_detail']?['name'] ?? ''; + return (jsondata["category_detail"]?["name"] ?? "") as String; } // Get the category description for the Part instance String get categoryDescription { // Invalid category ID - if (categoryId <= 0) return ''; + if (categoryId <= 0) return ""; - if (!jsondata.containsKey('category_detail')) return ''; + if (!jsondata.containsKey("category_detail")) return ""; - return jsondata['category_detail']?['description'] ?? ''; + return (jsondata["category_detail"]?["description"] ?? "") as String; } // Get the image URL for the Part instance - String get _image => jsondata['image'] ?? ''; + String get _image => (jsondata["image"] ?? "") as String; // Get the thumbnail URL for the Part instance - String get _thumbnail => jsondata['thumbnail'] ?? ''; + String get _thumbnail => (jsondata["thumbnail"] ?? "") as String; // Return the fully-qualified name for the Part instance String get fullname { - String fn = jsondata['full_name'] ?? ''; + String fn = (jsondata["full_name"] ?? "") as String; if (fn.isNotEmpty) return fn; @@ -369,21 +372,15 @@ class InvenTreePart extends InvenTreeModel { final APIResponse response = await InvenTreeAPI().uploadFile( url, image, - method: 'PATCH', - name: 'image', + method: "PATCH", + name: "image", ); return response.successful(); } // Return the "starred" status of this part - bool get starred => (jsondata['starred'] ?? false) as bool; - - InvenTreePart() : super(); - - InvenTreePart.fromJson(Map json) : super.fromJson(json) { - // TODO - } + bool get starred => (jsondata["starred"] ?? false) as bool; @override InvenTreeModel createFromJson(Map json) { @@ -399,11 +396,11 @@ class InvenTreePartAttachment extends InvenTreeAttachment { InvenTreePartAttachment() : super(); + InvenTreePartAttachment.fromJson(Map json) : super.fromJson(json); + @override String get URL => "part/attachment/"; - InvenTreePartAttachment.fromJson(Map json) : super.fromJson(json); - @override InvenTreeModel createFromJson(Map json) { return InvenTreePartAttachment.fromJson(json); diff --git a/lib/inventree/purchase_order.dart b/lib/inventree/purchase_order.dart new file mode 100644 index 00000000..6b8595cf --- /dev/null +++ b/lib/inventree/purchase_order.dart @@ -0,0 +1,205 @@ +import "package:inventree/inventree/company.dart"; +import "package:inventree/inventree/part.dart"; + +import "package:inventree/inventree/model.dart"; + +// TODO: In the future, status codes should be retrieved from the server +const int PO_STATUS_PENDING = 10; +const int PO_STATUS_PLACED = 20; +const int PO_STATUS_COMPLETE = 30; +const int PO_STATUS_CANCELLED = 40; +const int PO_STATUS_LOST = 50; +const int PO_STATUS_RETURNED = 60; + +class InvenTreePurchaseOrder extends InvenTreeModel { + + InvenTreePurchaseOrder() : super(); + + InvenTreePurchaseOrder.fromJson(Map json) : super.fromJson(json); + + @override + String get URL => "order/po/"; + + String get receive_url => "${url}receive/"; + + @override + Map formFields() { + return { + "reference": {}, + "supplier_reference": {}, + "description": {}, + "target_date": {}, + "link": {}, + "responsible": {}, + }; + } + + @override + Map defaultGetFilters() { + return { + "supplier_detail": "true", + }; + } + + @override + Map defaultListFilters() { + return { + "supplier_detail": "true", + }; + } + + String get issueDate => (jsondata["issue_date"] ?? "") as String; + + String get completeDate => (jsondata["complete_date"] ?? "") as String; + + String get creationDate => (jsondata["creation_date"] ?? "") as String; + + String get targetDate => (jsondata["target_date"] ?? "") as String; + + int get lineItemCount => (jsondata["line_items"] ?? 0) as int; + + bool get overdue => (jsondata["overdue"] ?? false) as bool; + + String get reference => (jsondata["reference"] ?? "") as String; + + int get responsibleId => (jsondata["responsible"] ?? -1) as int; + + int get supplierId => (jsondata["supplier"] ?? -1) as int; + + InvenTreeCompany? get supplier { + + dynamic supplier_detail = jsondata["supplier_detail"]; + + if (supplier_detail == null) { + return null; + } else { + return InvenTreeCompany.fromJson(supplier_detail as Map); + } + } + + String get supplierReference => (jsondata["supplier_reference"] ?? "") as String; + + int get status => (jsondata["status"] ?? -1) as int; + + String get statusText => (jsondata["status_text"] ?? "") as String; + + bool get isOpen => status == PO_STATUS_PENDING || status == PO_STATUS_PLACED; + + bool get isPlaced => status == PO_STATUS_PLACED; + + bool get isFailed => status == PO_STATUS_CANCELLED || status == PO_STATUS_LOST || status == PO_STATUS_RETURNED; + + Future> getLineItems() async { + + final results = await InvenTreePOLineItem().list( + filters: { + "order": "${pk}", + } + ); + + List items = []; + + for (var result in results) { + if (result is InvenTreePOLineItem) { + items.add(result); + } + } + + return items; + } + + @override + InvenTreeModel createFromJson(Map json) { + return InvenTreePurchaseOrder.fromJson(json); + } +} + +class InvenTreePOLineItem extends InvenTreeModel { + + InvenTreePOLineItem() : super(); + + InvenTreePOLineItem.fromJson(Map json) : super.fromJson(json); + + @override + String get URL => "order/po-line/"; + + @override + Map formFields() { + return { + // TODO: @Guusggg Not sure what will come here. + // "quantity": {}, + // "reference": {}, + // "notes": {}, + // "order": {}, + // "part": {}, + "received": {}, + // "purchase_price": {}, + // "purchase_price_currency": {}, + // "destination": {} + }; + } + + @override + Map defaultGetFilters() { + return { + "part_detail": "true", + }; + } + + @override + Map defaultListFilters() { + return { + "part_detail": "true", + }; + } + + bool get isComplete => received >= quantity; + + double get quantity => (jsondata["quantity"] ?? 0) as double; + + double get received => (jsondata["received"] ?? 0) as double; + + double get outstanding => quantity - received; + + String get reference => (jsondata["reference"] ?? "") as String; + + int get orderId => (jsondata["order"] ?? -1) as int; + + int get supplierPartId => (jsondata["part"] ?? -1) as int; + + InvenTreePart? get part { + dynamic part_detail = jsondata["part_detail"]; + + if (part_detail == null) { + return null; + } else { + return InvenTreePart.fromJson(part_detail as Map); + } + } + + InvenTreeSupplierPart? get supplierPart { + + dynamic detail = jsondata["supplier_part_detail"]; + + if (detail == null) { + return null; + } else { + return InvenTreeSupplierPart.fromJson(detail as Map); + } + } + + double get purchasePrice => double.parse((jsondata["purchase_price"] ?? "") as String); + + String get purchasePriceCurrency => (jsondata["purchase_price_currency"] ?? "") as String; + + String get purchasePriceString => (jsondata["purchase_price_string"] ?? "") as String; + + int get destination => (jsondata["destination"] ?? -1) as int; + + Map get destinationDetail => (jsondata["destination_detail"] ?? {}) as Map; + + @override + InvenTreeModel createFromJson(Map json) { + return InvenTreePOLineItem.fromJson(json); + } +} diff --git a/lib/inventree/sentry.dart b/lib/inventree/sentry.dart index 50ae4ee4..46a412a7 100644 --- a/lib/inventree/sentry.dart +++ b/lib/inventree/sentry.dart @@ -1,10 +1,10 @@ -import 'dart:io'; +import "dart:io"; -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; +import "package:device_info_plus/device_info_plus.dart"; +import "package:package_info_plus/package_info_plus.dart"; +import "package:sentry_flutter/sentry_flutter.dart"; -import 'package:inventree/api.dart'; +import "package:inventree/api.dart"; Future> getDeviceInfo() async { @@ -18,35 +18,35 @@ Future> getDeviceInfo() async { final iosDeviceInfo = await deviceInfo.iosInfo; device_info = { - 'name': iosDeviceInfo.name, - 'model': iosDeviceInfo.model, - 'systemName': iosDeviceInfo.systemName, - 'systemVersion': iosDeviceInfo.systemVersion, - 'localizedModel': iosDeviceInfo.localizedModel, - 'utsname': iosDeviceInfo.utsname.sysname, - 'identifierForVendor': iosDeviceInfo.identifierForVendor, - 'isPhysicalDevice': iosDeviceInfo.isPhysicalDevice, + "name": iosDeviceInfo.name, + "model": iosDeviceInfo.model, + "systemName": iosDeviceInfo.systemName, + "systemVersion": iosDeviceInfo.systemVersion, + "localizedModel": iosDeviceInfo.localizedModel, + "utsname": iosDeviceInfo.utsname.sysname, + "identifierForVendor": iosDeviceInfo.identifierForVendor, + "isPhysicalDevice": iosDeviceInfo.isPhysicalDevice, }; } else if (Platform.isAndroid) { final androidDeviceInfo = await deviceInfo.androidInfo; device_info = { - 'type': androidDeviceInfo.type, - 'model': androidDeviceInfo.model, - 'device': androidDeviceInfo.device, - 'id': androidDeviceInfo.id, - 'androidId': androidDeviceInfo.androidId, - 'brand': androidDeviceInfo.brand, - 'display': androidDeviceInfo.display, - 'hardware': androidDeviceInfo.hardware, - 'manufacturer': androidDeviceInfo.manufacturer, - 'product': androidDeviceInfo.product, - 'version': androidDeviceInfo.version.release, - 'supported32BitAbis': androidDeviceInfo.supported32BitAbis, - 'supported64BitAbis': androidDeviceInfo.supported64BitAbis, - 'supportedAbis': androidDeviceInfo.supportedAbis, - 'isPhysicalDevice': androidDeviceInfo.isPhysicalDevice, + "type": androidDeviceInfo.type, + "model": androidDeviceInfo.model, + "device": androidDeviceInfo.device, + "id": androidDeviceInfo.id, + "androidId": androidDeviceInfo.androidId, + "brand": androidDeviceInfo.brand, + "display": androidDeviceInfo.display, + "hardware": androidDeviceInfo.hardware, + "manufacturer": androidDeviceInfo.manufacturer, + "product": androidDeviceInfo.product, + "version": androidDeviceInfo.version.release, + "supported32BitAbis": androidDeviceInfo.supported32BitAbis, + "supported64BitAbis": androidDeviceInfo.supported64BitAbis, + "supportedAbis": androidDeviceInfo.supportedAbis, + "isPhysicalDevice": androidDeviceInfo.isPhysicalDevice, }; } @@ -90,7 +90,7 @@ Future sentryReportMessage(String message, {Map? context}) if (isInDebugMode()) { - print('----- In dev mode. Not sending message to Sentry.io -----'); + print("----- In dev mode. Not sending message to Sentry.io -----"); return true; } @@ -117,7 +117,7 @@ Future sentryReportMessage(String message, {Map? context}) Future sentryReportError(dynamic error, dynamic stackTrace) async { - print('----- Sentry Intercepted error: $error -----'); + print("----- Sentry Intercepted error: $error -----"); print(stackTrace); // Errors thrown in development mode are unlikely to be interesting. You can @@ -125,7 +125,7 @@ Future sentryReportError(dynamic error, dynamic stackTrace) async { // the report. if (isInDebugMode()) { - print('----- In dev mode. Not sending report to Sentry.io -----'); + print("----- In dev mode. Not sending report to Sentry.io -----"); return; } diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 795dbda7..29391b26 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -1,19 +1,23 @@ -import 'package:intl/intl.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:http/http.dart' as http; -import 'model.dart'; -import 'package:inventree/l10.dart'; +import "dart:async"; +import "package:flutter/material.dart"; +import "package:intl/intl.dart"; +import "package:inventree/helpers.dart"; +import "package:inventree/inventree/part.dart"; +import "package:flutter/cupertino.dart"; -import 'dart:async'; -import 'dart:io'; +import "package:inventree/inventree/model.dart"; +import "package:inventree/l10.dart"; -import 'package:inventree/api.dart'; +import "package:inventree/api.dart"; class InvenTreeStockItemTestResult extends InvenTreeModel { + InvenTreeStockItemTestResult() : super(); + + InvenTreeStockItemTestResult.fromJson(Map json) : super.fromJson(json); + @override String get URL => "stock/test/"; @@ -31,23 +35,17 @@ class InvenTreeStockItemTestResult extends InvenTreeModel { }; } - String get key => jsondata['key'] ?? ''; + String get key => (jsondata["key"] ?? "") as String; - String get testName => jsondata['test'] ?? ''; + String get testName => (jsondata["test"] ?? "") as String; - bool get result => jsondata['result'] ?? false; + bool get result => (jsondata["result"] ?? false) as bool; - String get value => jsondata['value'] ?? ''; + String get value => (jsondata["value"] ?? "") as String; - String get notes => jsondata['notes'] ?? ''; + String get attachment => (jsondata["attachment"] ?? "") as String; - String get attachment => jsondata['attachment'] ?? ''; - - String get date => jsondata['date'] ?? ''; - - InvenTreeStockItemTestResult() : super(); - - InvenTreeStockItemTestResult.fromJson(Map json) : super.fromJson(json); + String get date => (jsondata["date"] ?? "") as String; @override InvenTreeStockItemTestResult createFromJson(Map json) { @@ -60,6 +58,10 @@ class InvenTreeStockItemTestResult extends InvenTreeModel { class InvenTreeStockItem extends InvenTreeModel { + InvenTreeStockItem() : super(); + + InvenTreeStockItem.fromJson(Map json) : super.fromJson(json); + // Stock status codes static const int OK = 10; static const int ATTENTION = 50; @@ -97,7 +99,7 @@ class InvenTreeStockItem extends InvenTreeModel { Color get statusColor { switch (status) { case OK: - return Color(0xFF50aa51); + return Colors.black; case ATTENTION: return Color(0xFFfdc82a); case DAMAGED: @@ -114,7 +116,7 @@ class InvenTreeStockItem extends InvenTreeModel { String get URL => "stock/"; @override - String WEB_URL = "stock/item/"; + String get WEB_URL => "stock/item/"; @override Map formFields() { @@ -132,33 +134,24 @@ class InvenTreeStockItem extends InvenTreeModel { @override Map defaultGetFilters() { - var headers = new Map(); - - headers["part_detail"] = "true"; - headers["location_detail"] = "true"; - headers["supplier_detail"] = "true"; - headers["cascade"] = "false"; - - return headers; + return { + "part_detail": "true", + "location_detail": "true", + "supplier_detail": "true", + "cascade": "false" + }; } @override Map defaultListFilters() { - var headers = new Map(); - - headers["part_detail"] = "true"; - headers["location_detail"] = "true"; - headers["supplier_detail"] = "true"; - headers["cascade"] = "false"; - - return headers; - } - - InvenTreeStockItem() : super(); - - InvenTreeStockItem.fromJson(Map json) : super.fromJson(json) { - // TODO + return { + "part_detail": "true", + "location_detail": "true", + "supplier_detail": "true", + "cascade": "false", + "in_stock": "true", + }; } List testTemplates = []; @@ -204,17 +197,17 @@ class InvenTreeStockItem extends InvenTreeModel { }); } - String get uid => jsondata['uid'] ?? ''; + String get uid => (jsondata["uid"] ?? "") as String; - int get status => jsondata['status'] ?? -1; + int get status => (jsondata["status"] ?? -1) as int; - String get packaging => jsondata["packaging"] ?? ""; + String get packaging => (jsondata["packaging"] ?? "") as String; - String get batch => jsondata["batch"] ?? ""; + String get batch => (jsondata["batch"] ?? "") as String; - int get partId => jsondata['part'] ?? -1; + int get partId => (jsondata["part"] ?? -1) as int; - String get purchasePrice => jsondata['purchase_price'] ?? ""; + String get purchasePrice => (jsondata["purchase_price"] ?? "") as String; bool get hasPurchasePrice { @@ -223,12 +216,14 @@ class InvenTreeStockItem extends InvenTreeModel { return pp.isNotEmpty && pp.trim() != "-"; } - int get trackingItemCount => (jsondata['tracking_items'] ?? 0) as int; + int get purchaseOrderId => (jsondata["purchase_order"] ?? -1) as int; + + int get trackingItemCount => (jsondata["tracking_items"] ?? 0) as int; // Date of last update DateTime? get updatedDate { if (jsondata.containsKey("updated")) { - return DateTime.tryParse(jsondata["updated"] ?? ''); + return DateTime.tryParse((jsondata["updated"] ?? "") as String); } else { return null; } @@ -248,7 +243,7 @@ class InvenTreeStockItem extends InvenTreeModel { DateTime? get stocktakeDate { if (jsondata.containsKey("stocktake_date")) { - return DateTime.tryParse(jsondata["stocktake_date"] ?? ''); + return DateTime.tryParse((jsondata["stocktake_date"] ?? "") as String); } else { return null; } @@ -268,45 +263,45 @@ class InvenTreeStockItem extends InvenTreeModel { String get partName { - String nm = ''; + String nm = ""; // Use the detailed part information as priority - if (jsondata.containsKey('part_detail')) { - nm = jsondata['part_detail']['full_name'] ?? ''; + if (jsondata.containsKey("part_detail")) { + nm = (jsondata["part_detail"]["full_name"] ?? "") as String; } // Backup if first value fails if (nm.isEmpty) { - nm = jsondata['part__name'] ?? ''; + nm = (jsondata["part__name"] ?? "") as String; } return nm; } String get partDescription { - String desc = ''; + String desc = ""; // Use the detailed part description as priority - if (jsondata.containsKey('part_detail')) { - desc = jsondata['part_detail']['description'] ?? ''; + if (jsondata.containsKey("part_detail")) { + desc = (jsondata["part_detail"]["description"] ?? "") as String; } if (desc.isEmpty) { - desc = jsondata['part__description'] ?? ''; + desc = (jsondata["part__description"] ?? "") as String; } return desc; } String get partImage { - String img = ''; + String img = ""; - if (jsondata.containsKey('part_detail')) { - img = jsondata['part_detail']['thumbnail'] ?? ''; + if (jsondata.containsKey("part_detail")) { + img = (jsondata["part_detail"]["thumbnail"] ?? "") as String; } if (img.isEmpty) { - img = jsondata['part__thumbnail'] ?? ''; + img = (jsondata["part__thumbnail"] ?? "") as String; } return img; @@ -319,107 +314,97 @@ class InvenTreeStockItem extends InvenTreeModel { String thumb = ""; - thumb = jsondata['part_detail']?['thumbnail'] ?? ''; + thumb = (jsondata["part_detail"]?["thumbnail"] ?? "") as String; - // Use 'image' as a backup + // Use "image" as a backup if (thumb.isEmpty) { - thumb = jsondata['part_detail']?['image'] ?? ''; + thumb = (jsondata["part_detail"]?["image"] ?? "") as String; } // Try a different approach if (thumb.isEmpty) { - thumb = jsondata['part__thumbnail'] ?? ''; + thumb = (jsondata["part__thumbnail"] ?? "") as String; } - // Still no thumbnail? Use the 'no image' image + // Still no thumbnail? Use the "no image" image if (thumb.isEmpty) thumb = InvenTreeAPI.staticThumb; return thumb; } - int get supplierPartId => (jsondata['supplier_part'] ?? -1) as int; + int get supplierPartId => (jsondata["supplier_part"] ?? -1) as int; String get supplierImage { - String thumb = ''; + String thumb = ""; if (jsondata.containsKey("supplier_detail")) { - thumb = jsondata['supplier_detail']['supplier_logo'] ?? ''; + thumb = (jsondata["supplier_detail"]["supplier_logo"] ?? "") as String; } return thumb; } String get supplierName { - String sname = ''; + String sname = ""; if (jsondata.containsKey("supplier_detail")) { - sname = jsondata["supplier_detail"]["supplier_name"] ?? ''; + sname = (jsondata["supplier_detail"]["supplier_name"] ?? "") as String; } return sname; } String get units { - return jsondata['part_detail']?['units'] ?? ''; + return (jsondata["part_detail"]?["units"] ?? "") as String; } String get supplierSKU { - String sku = ''; + String sku = ""; if (jsondata.containsKey("supplier_detail")) { - sku = jsondata["supplier_detail"]["SKU"] ?? ''; + sku = (jsondata["supplier_detail"]["SKU"] ?? "") as String; } return sku; } - String get serialNumber => jsondata['serial'] ?? ""; + String get serialNumber => (jsondata["serial"] ?? "") as String; - double get quantity => double.tryParse(jsondata['quantity'].toString()) ?? 0; + double get quantity => double.tryParse(jsondata["quantity"].toString()) ?? 0; - String get quantityString { + String quantityString({bool includeUnits = false}){ - String q = quantity.toString(); + String q = simpleNumberString(quantity); - // Simplify integer values e.g. "1.0" becomes "1" - if (quantity.toInt() == quantity) { - q = quantity.toInt().toString(); - } - - if (units.isNotEmpty) { + if (includeUnits && units.isNotEmpty) { q += " ${units}"; } return q; } - int get locationId => (jsondata['location'] ?? -1) as int; + int get locationId => (jsondata["location"] ?? -1) as int; bool isSerialized() => serialNumber.isNotEmpty && quantity.toInt() == 1; String serialOrQuantityDisplay() { if (isSerialized()) { - return 'SN ${serialNumber}'; + return "SN ${serialNumber}"; } - // Is an integer? - if (quantity.toInt() == quantity) { - return '${quantity.toInt()}'; - } - - return '${quantity}'; + return simpleNumberString(quantity); } String get locationName { - String loc = ''; + String loc = ""; - if (locationId == -1 || !jsondata.containsKey('location_detail')) return 'Unknown Location'; + if (locationId == -1 || !jsondata.containsKey("location_detail")) return "Unknown Location"; - loc = jsondata['location_detail']['name'] ?? ''; + loc = (jsondata["location_detail"]["name"] ?? "") as String; // Old-style name if (loc.isEmpty) { - loc = jsondata['location__name'] ?? ''; + loc = (jsondata["location__name"] ?? "") as String; } return loc; @@ -427,9 +412,9 @@ class InvenTreeStockItem extends InvenTreeModel { String get locationPathString { - if (locationId == -1 || !jsondata.containsKey('location_detail')) return L10().locationNotSet; + if (locationId == -1 || !jsondata.containsKey("location_detail")) return L10().locationNotSet; - String _loc = jsondata['location_detail']['pathstring'] ?? ''; + String _loc = (jsondata["location_detail"]["pathstring"] ?? "") as String; if (_loc.isNotEmpty) { return _loc; @@ -444,7 +429,7 @@ class InvenTreeStockItem extends InvenTreeModel { if (serialNumber.isNotEmpty) { return "SN: $serialNumber"; } else { - return quantityString; + return simpleNumberString(quantity); } } @@ -481,7 +466,7 @@ class InvenTreeStockItem extends InvenTreeModel { "pk": "${pk}", "quantity": "${q}", }, - "notes": notes ?? '', + "notes": notes ?? "", }, expectedStatusCode: 200 ); @@ -489,6 +474,7 @@ class InvenTreeStockItem extends InvenTreeModel { return response.isValid(); } + // TODO: Refactor this once the server supports API metadata for this action Future countStock(BuildContext context, double q, {String? notes}) async { final bool result = await adjustStock(context, "/stock/count/", q, notes: notes); @@ -496,6 +482,7 @@ class InvenTreeStockItem extends InvenTreeModel { return result; } + // TODO: Refactor this once the server supports API metadata for this action Future addStock(BuildContext context, double q, {String? notes}) async { final bool result = await adjustStock(context, "/stock/add/", q, notes: notes); @@ -503,6 +490,7 @@ class InvenTreeStockItem extends InvenTreeModel { return result; } + // TODO: Refactor this once the server supports API metadata for this action Future removeStock(BuildContext context, double q, {String? notes}) async { final bool result = await adjustStock(context, "/stock/remove/", q, notes: notes); @@ -510,6 +498,7 @@ class InvenTreeStockItem extends InvenTreeModel { return result; } + // TODO: Refactor this once the server supports API metadata for this action Future transferStock(int location, {double? quantity, String? notes}) async { if ((quantity == null) || (quantity < 0) || (quantity > this.quantity)) { quantity = this.quantity; @@ -535,10 +524,14 @@ class InvenTreeStockItem extends InvenTreeModel { class InvenTreeStockLocation extends InvenTreeModel { + InvenTreeStockLocation() : super(); + + InvenTreeStockLocation.fromJson(Map json) : super.fromJson(json); + @override String get URL => "stock/location/"; - String get pathstring => jsondata['pathstring'] ?? ''; + String get pathstring => (jsondata["pathstring"] ?? "") as String; @override Map formFields() { @@ -551,13 +544,13 @@ class InvenTreeStockLocation extends InvenTreeModel { String get parentpathstring { // TODO - Drive the refactor tractor through this - List psplit = pathstring.split('/'); + List psplit = pathstring.split("/"); - if (psplit.length > 0) { + if (psplit.isNotEmpty) { psplit.removeLast(); } - String p = psplit.join('/'); + String p = psplit.join("/"); if (p.isEmpty) { p = "Top level stock location"; @@ -566,11 +559,7 @@ class InvenTreeStockLocation extends InvenTreeModel { return p; } - int get itemcount => jsondata['items'] ?? 0; - - InvenTreeStockLocation() : super(); - - InvenTreeStockLocation.fromJson(Map json) : super.fromJson(json); + int get itemcount => (jsondata["items"] ?? 0) as int; @override InvenTreeModel createFromJson(Map json) { diff --git a/lib/l10.dart b/lib/l10.dart index 04b70f40..ce9f3199 100644 --- a/lib/l10.dart +++ b/lib/l10.dart @@ -1,8 +1,8 @@ -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations_en.dart'; +import "package:flutter_gen/gen_l10n/app_localizations.dart"; +import "package:flutter_gen/gen_l10n/app_localizations_en.dart"; -import 'package:one_context/one_context.dart'; -import 'package:flutter/material.dart'; +import "package:one_context/one_context.dart"; +import "package:flutter/material.dart"; // Shortcut function to reduce boilerplate! I18N L10() diff --git a/lib/l10n b/lib/l10n index 3c7806d0..d004dc01 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 3c7806d03887b8380efa22b8c1ca0e3eca2b98ad +Subproject commit d004dc013edfc47b5ed94c5b019a013dc5ef444a diff --git a/lib/main.dart b/lib/main.dart index 40c7176a..50561cbe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,19 +1,18 @@ -import 'dart:async'; +import "dart:async"; -import 'package:inventree/inventree/sentry.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import "package:flutter_localizations/flutter_localizations.dart"; +import "package:flutter_gen/gen_l10n/app_localizations.dart"; -import 'package:inventree/widget/home.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:one_context/one_context.dart'; -import 'package:package_info_plus/package_info_plus.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:one_context/one_context.dart"; +import "package:package_info_plus/package_info_plus.dart"; +import "package:flutter/foundation.dart"; +import "package:sentry_flutter/sentry_flutter.dart"; -import 'dsn.dart'; - -import 'package:flutter/foundation.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; +import "package:inventree/inventree/sentry.dart"; +import "package:inventree/dsn.dart"; +import "package:inventree/widget/home.dart"; Future main() async { @@ -75,24 +74,24 @@ class InvenTreeApp extends StatelessWidget { GlobalCupertinoLocalizations.delegate, ], supportedLocales: [ - const Locale('de', ''), // German - const Locale('el', ''), // Greek - const Locale('en', ''), // English - const Locale('es', ''), // Spanish - const Locale('fr', ''), // French - const Locale('he', ''), // Hebrew - const Locale('it', ''), // Italian - const Locale('ja', ''), // Japanese - const Locale('ko', ''), // Korean - const Locale('nl', ''), // Dutch - const Locale('no', ''), // Norwegian - const Locale('pl', ''), // Polish - const Locale('ru', ''), // Russian - const Locale('sv', ''), // Swedish - const Locale('th', ''), // Thai - const Locale('tr', ''), // Turkish - const Locale('vi', ''), // Vietnamese - const Locale('zh-CN', ''), // Chinese + const Locale("de", ""), // German + const Locale("el", ""), // Greek + const Locale("en", ""), // English + const Locale("es", ""), // Spanish + const Locale("fr", ""), // French + const Locale("he", ""), // Hebrew + const Locale("it", ""), // Italian + const Locale("ja", ""), // Japanese + const Locale("ko", ""), // Korean + const Locale("nl", ""), // Dutch + const Locale("no", ""), // Norwegian + const Locale("pl", ""), // Polish + const Locale("ru", ""), // Russian + const Locale("sv", ""), // Swedish + const Locale("th", ""), // Thai + const Locale("tr", ""), // Turkish + const Locale("vi", ""), // Vietnamese + const Locale("zh-CN", ""), // Chinese ], ); diff --git a/lib/preferences.dart b/lib/preferences.dart index f6e2372f..2c52a88d 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -1,20 +1,22 @@ -import 'package:path_provider/path_provider.dart'; -import 'package:sembast/sembast.dart'; -import 'package:sembast/sembast_io.dart'; -import 'package:path/path.dart'; -import 'dart:async'; +import "dart:async"; + +import "package:path_provider/path_provider.dart"; +import "package:sembast/sembast.dart"; +import "package:sembast/sembast_io.dart"; +import "package:path/path.dart"; + /* * Class for storing InvenTree preferences in a NoSql DB */ class InvenTreePreferencesDB { + InvenTreePreferencesDB._(); + static final InvenTreePreferencesDB _singleton = InvenTreePreferencesDB._(); static InvenTreePreferencesDB get instance => _singleton; - InvenTreePreferencesDB._(); - Completer _dbOpenCompleter = Completer(); bool isOpen = false; @@ -34,7 +36,7 @@ class InvenTreePreferencesDB { return _dbOpenCompleter.future; } - Future _openDatabase() async { + Future _openDatabase() async { // Get a platform-specific directory where persistent app data can be stored final appDocumentDir = await getApplicationDocumentsDirectory(); @@ -43,7 +45,7 @@ class InvenTreePreferencesDB { print("Path: ${appDocumentDir.path}"); // Path with the form: /platform-specific-directory/demo.db - final dbPath = join(appDocumentDir.path, 'InvenTreeSettings.db'); + final dbPath = join(appDocumentDir.path, "InvenTreeSettings.db"); final database = await databaseFactoryIo.openDatabase(dbPath); @@ -54,8 +56,14 @@ class InvenTreePreferencesDB { class InvenTreePreferences { + factory InvenTreePreferences() { + return _api; + } + + InvenTreePreferences._internal(); + /* The following settings are not stored to persistent storage, - * instead they are only used as 'session preferences'. + * instead they are only used as "session preferences". * They are kept here as a convenience only. */ @@ -72,11 +80,6 @@ class InvenTreePreferences { bool expandStockList = true; // Ensure we only ever create a single instance of the preferences class - static final InvenTreePreferences _api = new InvenTreePreferences._internal(); + static final InvenTreePreferences _api = InvenTreePreferences._internal(); - factory InvenTreePreferences() { - return _api; - } - - InvenTreePreferences._internal(); } \ No newline at end of file diff --git a/lib/settings/about.dart b/lib/settings/about.dart index de208666..cde55bf5 100644 --- a/lib/settings/about.dart +++ b/lib/settings/about.dart @@ -1,22 +1,22 @@ -import 'package:inventree/api.dart'; -import 'package:inventree/app_colors.dart'; -import 'package:inventree/settings/release.dart'; +import "package:inventree/api.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/settings/release.dart"; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:package_info_plus/package_info_plus.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:package_info_plus/package_info_plus.dart"; -import 'package:inventree/l10.dart'; +import "package:inventree/l10.dart"; class InvenTreeAboutWidget extends StatelessWidget { + const InvenTreeAboutWidget(this.info) : super(); + final PackageInfo info; - InvenTreeAboutWidget(this.info) : super(); - - void _releaseNotes(BuildContext context) async { + Future _releaseNotes(BuildContext context) async { // Load release notes from external file String notes = await rootBundle.loadString("assets/release_notes.md"); @@ -27,7 +27,7 @@ class InvenTreeAboutWidget extends StatelessWidget { ); } - void _credits(BuildContext context) async { + Future _credits(BuildContext context) async { String notes = await rootBundle.loadString("assets/credits.md"); diff --git a/lib/settings/app_settings.dart b/lib/settings/app_settings.dart index 0f0938b9..07d6dfd3 100644 --- a/lib/settings/app_settings.dart +++ b/lib/settings/app_settings.dart @@ -1,12 +1,12 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/cupertino.dart'; +import "package:flutter/material.dart"; +import "package:flutter/cupertino.dart"; -import 'package:inventree/l10.dart'; +import "package:inventree/l10.dart"; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; -import 'package:inventree/app_settings.dart'; +import "package:inventree/app_settings.dart"; class InvenTreeAppSettingsWidget extends StatefulWidget { @override @@ -15,10 +15,10 @@ class InvenTreeAppSettingsWidget extends StatefulWidget { class _InvenTreeAppSettingsState extends State { - final GlobalKey<_InvenTreeAppSettingsState> _settingsKey = GlobalKey<_InvenTreeAppSettingsState>(); - _InvenTreeAppSettingsState(); + final GlobalKey<_InvenTreeAppSettingsState> _settingsKey = GlobalKey<_InvenTreeAppSettingsState>(); + bool barcodeSounds = true; bool serverSounds = true; bool partSubcategory = false; @@ -31,7 +31,7 @@ class _InvenTreeAppSettingsState extends State { loadSettings(); } - void loadSettings() async { + Future loadSettings() async { barcodeSounds = await InvenTreeSettingsManager().getValue("barcodeSounds", true) as bool; serverSounds = await InvenTreeSettingsManager().getValue("serverSounds", true) as bool; @@ -42,35 +42,35 @@ class _InvenTreeAppSettingsState extends State { }); } - void setBarcodeSounds(bool en) async { + Future setBarcodeSounds(bool en) async { await InvenTreeSettingsManager().setValue("barcodeSounds", en); - barcodeSounds = await InvenTreeSettingsManager().getValue("barcodeSounds", true); + barcodeSounds = await InvenTreeSettingsManager().getBool("barcodeSounds", true); setState(() { }); } - void setServerSounds(bool en) async { + Future setServerSounds(bool en) async { await InvenTreeSettingsManager().setValue("serverSounds", en); - serverSounds = await InvenTreeSettingsManager().getValue("serverSounds", true); + serverSounds = await InvenTreeSettingsManager().getBool("serverSounds", true); setState(() { }); } - void setPartSubcategory(bool en) async { + Future setPartSubcategory(bool en) async { await InvenTreeSettingsManager().setValue("partSubcategory", en); - partSubcategory = await InvenTreeSettingsManager().getValue("partSubcategory", true); + partSubcategory = await InvenTreeSettingsManager().getBool("partSubcategory", true); setState(() { }); } - void setStockSublocation(bool en) async { + Future setStockSublocation(bool en) async { await InvenTreeSettingsManager().setValue("stockSublocation", en); - stockSublocation = await InvenTreeSettingsManager().getValue("stockSublocation", true); + stockSublocation = await InvenTreeSettingsManager().getBool("stockSublocation", true); setState(() { }); diff --git a/lib/settings/login.dart b/lib/settings/login.dart index 3bacea7f..086be345 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -1,15 +1,12 @@ -import 'package:inventree/app_colors.dart'; -import 'package:inventree/widget/dialogs.dart'; -import 'package:inventree/widget/fields.dart'; -import 'package:inventree/widget/spinner.dart'; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; - -import 'package:inventree/l10.dart'; - -import '../api.dart'; -import '../user_profile.dart'; +import "package:inventree/app_colors.dart"; +import "package:inventree/widget/dialogs.dart"; +import "package:inventree/widget/spinner.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/api.dart"; +import "package:inventree/user_profile.dart"; class InvenTreeLoginSettingsWidget extends StatefulWidget { @@ -20,17 +17,15 @@ class InvenTreeLoginSettingsWidget extends StatefulWidget { class _InvenTreeLoginSettingsState extends State { - final GlobalKey<_InvenTreeLoginSettingsState> _loginKey = GlobalKey<_InvenTreeLoginSettingsState>(); - - final GlobalKey _addProfileKey = new GlobalKey(); - - List profiles = []; - _InvenTreeLoginSettingsState() { _reload(); } - void _reload() async { + final GlobalKey<_InvenTreeLoginSettingsState> _loginKey = GlobalKey<_InvenTreeLoginSettingsState>(); + + List profiles = []; + + Future _reload() async { profiles = await UserProfileDBManager().getAllProfiles(); @@ -40,17 +35,6 @@ class _InvenTreeLoginSettingsState extends State { void _editProfile(BuildContext context, {UserProfile? userProfile, bool createNew = false}) { - var _name; - var _server; - var _username; - var _password; - - UserProfile? profile; - - if (userProfile != null) { - profile = userProfile; - } - Navigator.push( context, MaterialPageRoute( @@ -61,7 +45,7 @@ class _InvenTreeLoginSettingsState extends State { }); } - void _selectProfile(BuildContext context, UserProfile profile) async { + Future _selectProfile(BuildContext context, UserProfile profile) async { // Disconnect InvenTree InvenTreeAPI().disconnectFromServer(); @@ -84,42 +68,24 @@ class _InvenTreeLoginSettingsState extends State { _reload(); } - void _deleteProfile(UserProfile profile) async { + Future _deleteProfile(UserProfile profile) async { await UserProfileDBManager().deleteProfile(profile); _reload(); - if (InvenTreeAPI().isConnected() && profile.key == (InvenTreeAPI().profile?.key ?? '')) { + if (InvenTreeAPI().isConnected() && profile.key == (InvenTreeAPI().profile?.key ?? "")) { InvenTreeAPI().disconnectFromServer(); } } - void _updateProfile(UserProfile? profile) async { - - if (profile == null) { - return; - } - - _reload(); - - if (InvenTreeAPI().isConnected() && InvenTreeAPI().profile != null && profile.key == (InvenTreeAPI().profile?.key ?? '')) { - // Attempt server login (this will load the newly selected profile - - InvenTreeAPI().connectToServer().then((result) { - _reload(); - }); - } - } - - Widget? _getProfileIcon(UserProfile profile) { // Not selected? No icon for you! if (!profile.selected) return null; // Selected, but (for some reason) not the same as the API... - if ((InvenTreeAPI().profile?.key ?? '') != profile.key) { + if ((InvenTreeAPI().profile?.key ?? "") != profile.key) { return FaIcon( FontAwesomeIcons.questionCircle, color: COLOR_WARNING @@ -150,7 +116,7 @@ class _InvenTreeLoginSettingsState extends State { List children = []; - if (profiles.length > 0) { + if (profiles.isNotEmpty) { for (int idx = 0; idx < profiles.length; idx++) { UserProfile profile = profiles[idx]; @@ -253,9 +219,9 @@ class _InvenTreeLoginSettingsState extends State { class ProfileEditWidget extends StatefulWidget { - UserProfile? profile; + const ProfileEditWidget(this.profile) : super(); - ProfileEditWidget(this.profile) : super(); + final UserProfile? profile; @override _ProfileEditState createState() => _ProfileEditState(profile); @@ -263,11 +229,11 @@ class ProfileEditWidget extends StatefulWidget { class _ProfileEditState extends State { - UserProfile? profile; - _ProfileEditState(this.profile) : super(); - final formKey = new GlobalKey(); + UserProfile? profile; + + final formKey = GlobalKey(); String name = ""; String server = ""; @@ -375,7 +341,7 @@ class _ProfileEditState extends State { if (uri.hasScheme) { print("Scheme: ${uri.scheme}"); - if (!(["http", "https"].contains(uri.scheme.toLowerCase()))) { + if (!["http", "https"].contains(uri.scheme.toLowerCase())) { return L10().serverStart; } } else { diff --git a/lib/settings/release.dart b/lib/settings/release.dart index aa456d7c..35e60a44 100644 --- a/lib/settings/release.dart +++ b/lib/settings/release.dart @@ -1,14 +1,14 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:inventree/l10.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_markdown/flutter_markdown.dart"; +import "package:inventree/l10.dart"; class ReleaseNotesWidget extends StatelessWidget { - final String releaseNotes; + const ReleaseNotesWidget(this.releaseNotes); - ReleaseNotesWidget(this.releaseNotes); + final String releaseNotes; @override Widget build (BuildContext context) { @@ -27,9 +27,9 @@ class ReleaseNotesWidget extends StatelessWidget { class CreditsWidget extends StatelessWidget { - final String credits; + const CreditsWidget(this.credits); - CreditsWidget(this.credits); + final String credits; @override Widget build (BuildContext context) { diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index b8bb4c37..946e94f8 100644 --- a/lib/settings/settings.dart +++ b/lib/settings/settings.dart @@ -1,18 +1,16 @@ -import 'package:inventree/app_colors.dart'; -import 'package:inventree/settings/about.dart'; -import 'package:inventree/settings/app_settings.dart'; -import 'package:inventree/settings/login.dart'; +import "package:inventree/app_colors.dart"; +import "package:inventree/settings/about.dart"; +import "package:inventree/settings/app_settings.dart"; +import "package:inventree/settings/login.dart"; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/l10.dart'; -import 'package:inventree/widget/submit_feedback.dart'; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/widget/submit_feedback.dart"; -import 'package:url_launcher/url_launcher.dart'; +import "package:url_launcher/url_launcher.dart"; -import 'login.dart'; - -import 'package:package_info_plus/package_info_plus.dart'; +import "package:package_info_plus/package_info_plus.dart"; class InvenTreeSettingsWidget extends StatefulWidget { // InvenTree settings view @@ -95,30 +93,30 @@ class _InvenTreeSettingsState extends State { } - void _openDocs() async { + Future _openDocs() async { if (await canLaunch(docsUrl)) { await launch(docsUrl); } } - void _translate() async { - final String url = "https://crowdin.com/project/inventree"; + Future _translate() async { + const String url = "https://crowdin.com/project/inventree"; if (await canLaunch(url)) { await launch(url); } } - void _editServerSettings() async { + Future _editServerSettings() async { Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget())); } - void _editAppSettings() async { + Future _editAppSettings() async { Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeAppSettingsWidget())); } - void _about() async { + Future _about() async { PackageInfo.fromPlatform().then((PackageInfo info) { Navigator.push(context, @@ -126,7 +124,7 @@ class _InvenTreeSettingsState extends State { }); } - void _submitFeedback(BuildContext context) async { + Future _submitFeedback(BuildContext context) async { Navigator.push( context, diff --git a/lib/user_profile.dart b/lib/user_profile.dart index 3b6bf8f2..3aac40bf 100644 --- a/lib/user_profile.dart +++ b/lib/user_profile.dart @@ -2,8 +2,8 @@ /* * Class for InvenTree user / login details */ -import 'package:sembast/sembast.dart'; -import 'preferences.dart'; +import "package:sembast/sembast.dart"; +import "preferences.dart"; class UserProfile { @@ -16,6 +16,15 @@ class UserProfile { this.selected = false, }); + factory UserProfile.fromJson(int key, Map json, bool isSelected) => UserProfile( + key: key, + name: json["name"] as String, + server: json["server"] as String, + username: json["username"] as String, + password: json["password"] as String, + selected: isSelected, + ); + // ID of the profile int? key; @@ -36,15 +45,6 @@ class UserProfile { // User ID (will be provided by the server on log-in) int user_id = -1; - factory UserProfile.fromJson(int key, Map json, bool isSelected) => UserProfile( - key: key, - name: json['name'], - server: json['server'], - username: json['username'], - password: json['password'], - selected: isSelected, - ); - Map toJson() => { "name": name, "server": server, @@ -62,7 +62,7 @@ class UserProfileDBManager { final store = StoreRef("profiles"); - Future get _db async => await InvenTreePreferencesDB.instance.database; + Future get _db async => InvenTreePreferencesDB.instance.database; Future profileNameExists(String name) async { @@ -70,10 +70,10 @@ class UserProfileDBManager { final profiles = await store.find(await _db, finder: finder); - return profiles.length > 0; + return profiles.isNotEmpty; } - Future addProfile(UserProfile profile) async { + Future addProfile(UserProfile profile) async { // Check if a profile already exists with the name final bool exists = await profileNameExists(profile.name); @@ -83,7 +83,7 @@ class UserProfileDBManager { return; } - int key = await store.add(await _db, profile.toJson()); + int key = await store.add(await _db, profile.toJson()) as int; print("Added user profile <${key}> - '${profile.name}'"); @@ -91,7 +91,7 @@ class UserProfileDBManager { profile.key = key; } - Future selectProfile(int key) async { + Future selectProfile(int key) async { /* * Mark the particular profile as selected */ @@ -101,7 +101,7 @@ class UserProfileDBManager { return result; } - Future updateProfile(UserProfile profile) async { + Future updateProfile(UserProfile profile) async { if (profile.key == null) { await addProfile(profile); @@ -115,7 +115,7 @@ class UserProfileDBManager { return result; } - Future deleteProfile(UserProfile profile) async { + Future deleteProfile(UserProfile profile) async { await store.record(profile.key).delete(await _db); print("Deleted user profile <${profile.key}> - '${profile.name}'"); } @@ -135,8 +135,8 @@ class UserProfileDBManager { if (profiles[idx].key is int && profiles[idx].key == selected) { return UserProfile.fromJson( - profiles[idx].key, - profiles[idx].value, + profiles[idx].key as int, + profiles[idx].value as Map, profiles[idx].key == selected, ); } @@ -161,8 +161,8 @@ class UserProfileDBManager { if (profiles[idx].key is int) { profileList.add( UserProfile.fromJson( - profiles[idx].key, - profiles[idx].value, + profiles[idx].key as int, + profiles[idx].value as Map, profiles[idx].key == selected, )); } diff --git a/lib/widget/back.dart b/lib/widget/back.dart new file mode 100644 index 00000000..be3ade92 --- /dev/null +++ b/lib/widget/back.dart @@ -0,0 +1,27 @@ +/* + * A custom implementation of a "Back" button for display in the app drawer + * + * Long-pressing on this will return the user to the home screen + */ + +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; + +Widget backButton(BuildContext context, GlobalKey key) { + + return GestureDetector( + onLongPress: () { + // Display the menu + key.currentState!.openDrawer(); + print("hello?"); + }, + child: IconButton( + icon: BackButtonIcon(), + onPressed: () { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ), + ); +} \ No newline at end of file diff --git a/lib/widget/category_display.dart b/lib/widget/category_display.dart index ea4e2e30..9e3e7e21 100644 --- a/lib/widget/category_display.dart +++ b/lib/widget/category_display.dart @@ -1,27 +1,22 @@ +import "package:flutter/cupertino.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; -import 'package:inventree/api.dart'; -import 'package:inventree/app_colors.dart'; -import 'package:inventree/app_settings.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/inventree/sentry.dart'; -import 'package:inventree/widget/progress.dart'; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; -import 'package:inventree/l10.dart'; +import "package:inventree/api.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/widget/part_list.dart"; +import "package:inventree/widget/progress.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/widget/part_detail.dart"; +import "package:inventree/widget/refreshable_state.dart"; -import 'package:inventree/widget/part_detail.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:inventree/widget/paginator.dart'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; class CategoryDisplayWidget extends StatefulWidget { - CategoryDisplayWidget(this.category, {Key? key}) : super(key: key); + const CategoryDisplayWidget(this.category, {Key? key}) : super(key: key); final InvenTreePartCategory? category; @@ -32,6 +27,7 @@ class CategoryDisplayWidget extends StatefulWidget { class _CategoryDisplayState extends RefreshableState { + _CategoryDisplayState(this.category); @override String getAppBarTitle(BuildContext context) => L10().partCategory; @@ -41,7 +37,7 @@ class _CategoryDisplayState extends RefreshableState { List actions = []; - if ((category != null) && InvenTreeAPI().checkPermission('part_category', 'change')) { + if ((category != null) && InvenTreeAPI().checkPermission("part_category", "change")) { actions.add( IconButton( icon: FaIcon(FontAwesomeIcons.edit), @@ -74,8 +70,6 @@ class _CategoryDisplayState extends RefreshableState { ); } - _CategoryDisplayState(this.category); - // The local InvenTreePartCategory object final InvenTreePartCategory? category; @@ -199,7 +193,7 @@ class _CategoryDisplayState extends RefreshableState { if (loading) { tiles.add(progressIndicator()); - } else if (_subcategories.length == 0) { + } else if (_subcategories.isEmpty) { tiles.add(ListTile( title: Text(L10().noSubcategories), subtitle: Text( @@ -224,8 +218,10 @@ class _CategoryDisplayState extends RefreshableState { data: { "parent": (pk > 0) ? pk : null, }, - onSuccess: (data) async { - + onSuccess: (result) async { + + Map data = result as Map; + if (data.containsKey("pk")) { var cat = InvenTreePartCategory.fromJson(data); @@ -252,7 +248,9 @@ class _CategoryDisplayState extends RefreshableState { data: { "category": (pk > 0) ? pk : null }, - onSuccess: (data) async { + onSuccess: (result) async { + + Map data = result as Map; if (data.containsKey("pk")) { var part = InvenTreePart.fromJson(data); @@ -274,7 +272,7 @@ class _CategoryDisplayState extends RefreshableState { getCategoryDescriptionCard(extra: false), ]; - if (InvenTreeAPI().checkPermission('part', 'add')) { + if (InvenTreeAPI().checkPermission("part", "add")) { tiles.add( ListTile( title: Text(L10().categoryCreate), @@ -298,7 +296,7 @@ class _CategoryDisplayState extends RefreshableState { } } - if (tiles.length == 0) { + if (tiles.isEmpty) { tiles.add( ListTile( title: Text( @@ -327,7 +325,9 @@ class _CategoryDisplayState extends RefreshableState { ); case 1: return PaginatedPartList( - {"category": "${category?.pk ?? null}"}, + { + "category": "${category?.pk ?? 'null'}" + }, ); case 2: return ListView( @@ -344,9 +344,10 @@ class _CategoryDisplayState extends RefreshableState { * Builder for displaying a list of PartCategory objects */ class SubcategoryList extends StatelessWidget { - final List _categories; - SubcategoryList(this._categories); + const SubcategoryList(this._categories); + + final List _categories; void _openCategory(BuildContext context, int pk) { @@ -381,170 +382,3 @@ class SubcategoryList extends StatelessWidget { itemBuilder: _build, itemCount: _categories.length); } } - - -/* - * Widget for displaying a list of Part objects within a PartCategory display. - * - * Uses server-side pagination for snappy results - */ - -class PaginatedPartList extends StatefulWidget { - - final Map filters; - - Function(int)? onTotalChanged; - - PaginatedPartList(this.filters, {this.onTotalChanged}); - - @override - _PaginatedPartListState createState() => _PaginatedPartListState(filters, onTotalChanged); -} - - -class _PaginatedPartListState extends State { - - static const _pageSize = 25; - - String _searchTerm = ""; - - Function(int)? onTotalChanged; - - final Map filters; - - _PaginatedPartListState(this.filters, this.onTotalChanged); - - final PagingController _pagingController = PagingController(firstPageKey: 0); - - @override - void initState() { - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); - - super.initState(); - } - - @override - void dispose() { - _pagingController.dispose(); - super.dispose(); - } - - int resultCount = 0; - - Future _fetchPage(int pageKey) async { - try { - - Map params = filters; - - params["search"] = _searchTerm; - - final bool cascade = await InvenTreeSettingsManager().getValue("partSubcategory", true); - params["cascade"] = "${cascade}"; - - final page = await InvenTreePart().listPaginated(_pageSize, pageKey, filters: params); - int pageLength = page?.length ?? 0; - int pageCount = page?.count ?? 0; - - final isLastPage = pageLength < _pageSize; - - // Construct a list of part objects - List parts = []; - - if (page != null) { - for (var result in page.results) { - if (result is InvenTreePart) { - parts.add(result); - } - } - } - - if (isLastPage) { - _pagingController.appendLastPage(parts); - } else { - final int nextPageKey = pageKey + pageLength; - _pagingController.appendPage(parts, nextPageKey); - } - - if (onTotalChanged != null) { - onTotalChanged!(pageCount); - } - - setState(() { - resultCount = pageCount; - }); - - } catch (error, stackTrace) { - print("Error! - ${error.toString()}"); - _pagingController.error = error; - - sentryReportError(error, stackTrace); - } - } - - void _openPart(BuildContext context, int pk) { - // Attempt to load the part information - InvenTreePart().get(pk).then((var part) { - if (part is InvenTreePart) { - - Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part))); - } - }); - } - - Widget _buildPart(BuildContext context, InvenTreePart part) { - return ListTile( - title: Text(part.fullname), - subtitle: Text("${part.description}"), - trailing: Text("${part.inStockString}"), - leading: InvenTreeAPI().getImage( - part.thumbnail, - width: 40, - height: 40, - ), - onTap: () { - _openPart(context, part.pk); - }, - ); - } - - final TextEditingController searchController = TextEditingController(); - - void updateSearchTerm() { - - _searchTerm = searchController.text; - _pagingController.refresh(); - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - PaginatedSearchWidget(searchController, updateSearchTerm, resultCount), - Expanded( - child: CustomScrollView( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - scrollDirection: Axis.vertical, - slivers: [ - PagedSliverList.separated( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) { - return _buildPart(context, item); - }, - noItemsFoundIndicatorBuilder: (context) { - return NoResultsWidget(L10().partNoResults); - }, - ), - separatorBuilder: (context, index) => const Divider(height: 1), - ) - ], - ) - ) - ], - ); - } -} diff --git a/lib/widget/category_list.dart b/lib/widget/category_list.dart new file mode 100644 index 00000000..33673d6c --- /dev/null +++ b/lib/widget/category_list.dart @@ -0,0 +1,81 @@ +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; + +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/widget/category_display.dart"; +import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/l10.dart"; + +class PartCategoryList extends StatefulWidget { + + const PartCategoryList(this.filters); + + final Map filters; + + @override + _PartCategoryListState createState() => _PartCategoryListState(filters); + +} + + +class _PartCategoryListState extends RefreshableState { + + _PartCategoryListState(this.filters); + + final Map filters; + + @override + String getAppBarTitle(BuildContext context) => L10().partCategories; + + @override + Widget getBody(BuildContext context) { + return PaginatedPartCategoryList(filters); + } +} + + +class PaginatedPartCategoryList extends StatefulWidget { + + const PaginatedPartCategoryList(this.filters); + + final Map filters; + + @override + _PaginatedPartCategoryListState createState() => _PaginatedPartCategoryListState(filters); +} + + +class _PaginatedPartCategoryListState extends PaginatedSearchState { + + _PaginatedPartCategoryListState(Map filters) : super(filters); + + @override + Future requestPage(int limit, int offset, Map params) async { + + final page = await InvenTreePartCategory().listPaginated(limit, offset, filters: params); + + return page; + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + + InvenTreePartCategory category = model as InvenTreePartCategory; + + return ListTile( + title: Text(category.name), + subtitle: Text(category.pathstring), + trailing: Text("${category.partcount}"), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CategoryDisplayWidget(category) + ) + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widget/company_detail.dart b/lib/widget/company_detail.dart index bc87322e..058249ba 100644 --- a/lib/widget/company_detail.dart +++ b/lib/widget/company_detail.dart @@ -1,19 +1,21 @@ -import 'package:inventree/api.dart'; -import 'package:inventree/api_form.dart'; -import 'package:inventree/app_colors.dart'; -import 'package:inventree/inventree/company.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/l10.dart'; +import "package:inventree/api.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/inventree/company.dart"; +import "package:inventree/inventree/purchase_order.dart"; +import "package:inventree/widget/purchase_order_list.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/l10.dart"; + class CompanyDetailWidget extends StatefulWidget { - final InvenTreeCompany company; + const CompanyDetailWidget(this.company, {Key? key}) : super(key: key); - CompanyDetailWidget(this.company, {Key? key}) : super(key: key); + final InvenTreeCompany company; @override _CompanyDetailState createState() => _CompanyDetailState(company); @@ -27,6 +29,8 @@ class _CompanyDetailState extends RefreshableState { final InvenTreeCompany company; + List outstandingOrders = []; + @override String getAppBarTitle(BuildContext context) => L10().company; @@ -61,9 +65,13 @@ class _CompanyDetailState extends RefreshableState { @override Future request() async { await company.reload(); + + if (company.isSupplier) { + outstandingOrders = await company.getPurchaseOrders(outstanding: true); + } } - void editCompany(BuildContext context) async { + Future editCompany(BuildContext context) async { company.editForm( context, @@ -146,6 +154,37 @@ class _CompanyDetailState extends RefreshableState { // TODO - Add list of purchase orders tiles.add(Divider()); + + tiles.add( + ListTile( + title: Text(L10().purchaseOrders), + leading: FaIcon(FontAwesomeIcons.shoppingCart, color: COLOR_CLICK), + trailing: Text("${outstandingOrders.length}"), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PurchaseOrderListWidget( + filters: { + "supplier": "${company.pk}" + } + ) + ) + ); + } + ) + ); + + // TODO: Display "supplied parts" count (click through to list of supplier parts) + /* + tiles.add( + ListTile( + title: Text(L10().suppliedParts), + leading: FaIcon(FontAwesomeIcons.shapes), + trailing: Text("${company.partSuppliedCount}"), + ) + ); + */ } if (company.isManufacturer) { diff --git a/lib/widget/company_list.dart b/lib/widget/company_list.dart index 15a4af98..c2bb5996 100644 --- a/lib/widget/company_list.dart +++ b/lib/widget/company_list.dart @@ -1,25 +1,22 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; -import 'package:inventree/api.dart'; -import 'package:inventree/inventree/company.dart'; -import 'package:inventree/inventree/sentry.dart'; -import 'package:inventree/widget/paginator.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:inventree/widget/company_detail.dart'; - -import '../l10.dart'; +import "package:inventree/api.dart"; +import "package:inventree/inventree/company.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/widget/company_detail.dart"; class CompanyListWidget extends StatefulWidget { - CompanyListWidget(this.title, this.filters, {Key? key}) : super(key: key); + const CompanyListWidget(this.title, this.filters, {Key? key}) : super(key: key); - String title; + final String title; - Map filters; + final Map filters; @override _CompanyListWidgetState createState() => _CompanyListWidgetState(title, filters); @@ -49,103 +46,32 @@ class _CompanyListWidgetState extends RefreshableState { class PaginatedCompanyList extends StatefulWidget { - PaginatedCompanyList(this.filters, {this.onTotalChanged}); + const PaginatedCompanyList(this.filters, {this.onTotalChanged}); final Map filters; - Function(int)? onTotalChanged; + final Function(int)? onTotalChanged; @override - _CompanyListState createState() => _CompanyListState(filters, onTotalChanged); + _CompanyListState createState() => _CompanyListState(filters); } -class _CompanyListState extends State { +class _CompanyListState extends PaginatedSearchState { - _CompanyListState(this.filters, this.onTotalChanged); - - static const _pageSize = 25; + _CompanyListState(Map filters) : super(filters); - String _searchTerm = ""; - - Function(int)? onTotalChanged; - - final Map filters; - - final PagingController _pagingController = PagingController(firstPageKey: 0); - - final TextEditingController searchController = TextEditingController(); - @override - void initState() { - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); - - super.initState(); + Future requestPage(int limit, int offset, Map params) async { + + final page = await InvenTreeCompany().listPaginated(limit, offset, filters: params); + + return page; } - + @override - void dispose() { - _pagingController.dispose(); - super.dispose(); - } - - int resultCount = 0; - - Future _fetchPage(int pageKey) async { - try { - Map params = filters; + Widget buildItem(BuildContext context, InvenTreeModel model) { - params["search"] = _searchTerm; - - final page = await InvenTreeCompany().listPaginated( - _pageSize, pageKey, filters: params); - - int pageLength = page?.length ?? 0; - int pageCount = page?.count ?? 0; - - final isLastPage = pageLength < _pageSize; - - List companies = []; - - if (page != null) { - for (var result in page.results) { - if (result is InvenTreeCompany) { - companies.add(result); - } else { - print(result.jsondata); - } - } - } - - if (isLastPage) { - _pagingController.appendLastPage(companies); - } else { - final int nextPageKey = pageKey + pageLength; - _pagingController.appendPage(companies, nextPageKey); - } - - if (onTotalChanged != null) { - onTotalChanged!(pageCount); - } - - setState(() { - resultCount = pageCount; - }); - } catch (error, stackTrace) { - print("Error! - ${error.toString()}"); - _pagingController.error = error; - - sentryReportError(error, stackTrace); - } - } - - void updateSearchTerm() { - _searchTerm = searchController.text; - _pagingController.refresh(); - } - - Widget _buildCompany(BuildContext context, InvenTreeCompany company) { + InvenTreeCompany company = model as InvenTreeCompany; return ListTile( title: Text(company.name), @@ -160,36 +86,4 @@ class _CompanyListState extends State { }, ); } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - PaginatedSearchWidget(searchController, updateSearchTerm, resultCount), - Expanded( - child: CustomScrollView( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - scrollDirection: Axis.vertical, - slivers: [ - PagedSliverList.separated( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) { - return _buildCompany(context, item); - }, - noItemsFoundIndicatorBuilder: (context) { - return NoResultsWidget(L10().companyNoResults); - } - ), - separatorBuilder: (context, index) => const Divider(height: 1), - ) - ], - ) - ) - ], - ); - } - } \ No newline at end of file diff --git a/lib/widget/dialogs.dart b/lib/widget/dialogs.dart index 641a55c9..2d80d650 100644 --- a/lib/widget/dialogs.dart +++ b/lib/widget/dialogs.dart @@ -1,12 +1,12 @@ -import 'package:inventree/app_settings.dart'; -import 'package:inventree/widget/snacks.dart'; -import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/l10.dart'; -import 'package:one_context/one_context.dart'; +import "package:inventree/app_settings.dart"; +import "package:inventree/widget/snacks.dart"; +import "package:audioplayers/audioplayers.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/l10.dart"; +import "package:one_context/one_context.dart"; Future confirmationDialog(String title, String text, {String? acceptText, String? rejectText, Function? onAccept, Function? onReject}) async { diff --git a/lib/widget/drawer.dart b/lib/widget/drawer.dart index 9fa9bde8..bb983c94 100644 --- a/lib/widget/drawer.dart +++ b/lib/widget/drawer.dart @@ -1,21 +1,17 @@ -import 'package:inventree/api.dart'; -import 'package:inventree/barcode.dart'; -import 'package:inventree/widget/company_list.dart'; -import 'package:inventree/widget/search.dart'; -import 'package:flutter/material.dart'; -import 'package:inventree/l10.dart'; +import "package:inventree/api.dart"; +import "package:inventree/barcode.dart"; +import "package:flutter/material.dart"; +import "package:inventree/l10.dart"; -import 'package:inventree/widget/category_display.dart'; -import 'package:inventree/widget/location_display.dart'; - -import 'package:inventree/settings/settings.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import "package:inventree/settings/settings.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/widget/search.dart"; class InvenTreeDrawer extends StatelessWidget { - final BuildContext context; + const InvenTreeDrawer(this.context); - InvenTreeDrawer(this.context); + final BuildContext context; void _closeDrawer() { // Close the drawer @@ -29,7 +25,9 @@ class InvenTreeDrawer extends StatelessWidget { void _home() { _closeDrawer(); - Navigator.pushNamedAndRemoveUntil(context, "/", (r) => false); + while (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } } void _search() { @@ -38,65 +36,25 @@ class InvenTreeDrawer extends StatelessWidget { _closeDrawer(); - showSearch( - context: context, - delegate: PartSearchDelegate(context) + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SearchWidget() + ) ); - - //Navigator.push(context, MaterialPageRoute(builder: (context) => SearchWidget())); } /* * Launch the camera to scan a QR code. * Upon successful scan, data are passed off to be decoded. */ - void _scan() async { + Future _scan() async { if (!InvenTreeAPI().checkConnection(context)) return; _closeDrawer(); scanQrCode(context); } - /* - * Display the top-level PartCategory list - */ - void _showParts() { - if (!InvenTreeAPI().checkConnection(context)) return; - - _closeDrawer(); - Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null))); - } - - /* - * Display the top-level StockLocation list - */ - void _showStock() { - if (!InvenTreeAPI().checkConnection(context)) return; - _closeDrawer(); - Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(null))); - } - - void _showSuppliers() { - if (!InvenTreeAPI().checkConnection(context)) return; - _closeDrawer(); - - Navigator.push(context, MaterialPageRoute(builder: (context) => CompanyListWidget(L10().suppliers, {"is_supplier": "true"}))); - } - - void _showManufacturers() { - if (!InvenTreeAPI().checkConnection(context)) return; - _closeDrawer(); - - Navigator.push(context, MaterialPageRoute(builder: (context) => CompanyListWidget(L10().manufacturers, {"is_manufacturer": "true"}))); - } - - void _showCustomers() { - if (!InvenTreeAPI().checkConnection(context)) return; - _closeDrawer(); - - Navigator.push(context, MaterialPageRoute(builder: (context) => CompanyListWidget(L10().customers, {"is_customer": "true"}))); - } - /* * Load settings widget */ @@ -107,17 +65,14 @@ class InvenTreeDrawer extends StatelessWidget { @override Widget build(BuildContext context) { + return Drawer( child: ListView( children: ListTile.divideTiles( context: context, tiles: [ ListTile( - leading: Image.asset( - "assets/image/icon.png", - fit: BoxFit.scaleDown, - width: 30, - ), + leading: FaIcon(FontAwesomeIcons.home), title: Text( L10().appTitle, style: TextStyle(fontWeight: FontWeight.bold), @@ -134,35 +89,6 @@ class InvenTreeDrawer extends StatelessWidget { leading: FaIcon(FontAwesomeIcons.search), onTap: _search, ), - ListTile( - title: Text(L10().parts), - leading: Icon(Icons.category), - onTap: _showParts, - ), - ListTile( - title: Text(L10().stock), - leading: FaIcon(FontAwesomeIcons.boxes), - onTap: _showStock, - ), - - /* - ListTile( - title: Text("Suppliers"), - leading: FaIcon(FontAwesomeIcons.building), - onTap: _showSuppliers, - ), - ListTile( - title: Text("Manufacturers"), - leading: FaIcon(FontAwesomeIcons.industry), - onTap: _showManufacturers, - ), - ListTile( - title: Text("Customers"), - leading: FaIcon(FontAwesomeIcons.users), - onTap: _showCustomers, - ), - */ - ListTile( title: Text(L10().settings), leading: Icon(Icons.settings), diff --git a/lib/widget/fields.dart b/lib/widget/fields.dart index f8016075..eb5b7b1a 100644 --- a/lib/widget/fields.dart +++ b/lib/widget/fields.dart @@ -1,15 +1,13 @@ +import "dart:async"; +import "dart:io"; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:inventree/l10.dart'; - -import 'dart:async'; -import 'dart:io'; - -import 'package:one_context/one_context.dart'; +import "package:file_picker/file_picker.dart"; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:image_picker/image_picker.dart"; +import "package:one_context/one_context.dart"; +import "package:inventree/l10.dart"; class FilePickerDialog { @@ -167,7 +165,7 @@ class CheckBoxField extends FormField { class StringField extends TextFormField { - StringField({String label = "", String? hint, String? initial, Function(String?)? onSaved, Function? validator, bool allowEmpty = false, bool isEnabled = true}) : + StringField({String label = "", String? hint, String? initial, Function(String?)? onSaved, Function(String?)? validator, bool allowEmpty = false, bool isEnabled = true}) : super( decoration: InputDecoration( labelText: allowEmpty ? label : label + "*", @@ -182,7 +180,7 @@ class StringField extends TextFormField { } if (validator != null) { - return validator(value); + return validator(value) as String?; } return null; @@ -196,7 +194,7 @@ class StringField extends TextFormField { */ class QuantityField extends TextFormField { - QuantityField({String label = "", String hint = "", String initial = "", double? max, TextEditingController? controller}) : + QuantityField({String label = "", String hint = "", double? max, TextEditingController? controller}) : super( decoration: InputDecoration( labelText: label, diff --git a/lib/widget/home.dart b/lib/widget/home.dart index 08c3a5ef..c72ebcae 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -1,27 +1,28 @@ -import 'package:inventree/app_colors.dart'; -import 'package:inventree/user_profile.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; -import 'package:inventree/l10.dart'; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import "package:inventree/app_colors.dart"; +import "package:inventree/settings/settings.dart"; +import "package:inventree/user_profile.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/barcode.dart"; +import "package:inventree/api.dart"; +import "package:inventree/settings/login.dart"; +import "package:inventree/widget/category_display.dart"; +import "package:inventree/widget/company_list.dart"; +import "package:inventree/widget/location_display.dart"; +import "package:inventree/widget/part_list.dart"; +import "package:inventree/widget/purchase_order_list.dart"; +import "package:inventree/widget/search.dart"; +import "package:inventree/widget/snacks.dart"; +import "package:inventree/widget/drawer.dart"; -import 'package:inventree/barcode.dart'; -import 'package:inventree/api.dart'; - -import 'package:inventree/settings/login.dart'; - -import 'package:inventree/widget/category_display.dart'; -import 'package:inventree/widget/company_list.dart'; -import 'package:inventree/widget/location_display.dart'; -import 'package:inventree/widget/search.dart'; -import 'package:inventree/widget/spinner.dart'; -import 'package:inventree/widget/drawer.dart'; class InvenTreeHomePage extends StatefulWidget { - InvenTreeHomePage({Key? key}) : super(key: key); + const InvenTreeHomePage({Key? key}) : super(key: key); @override _InvenTreeHomePageState createState() => _InvenTreeHomePageState(); @@ -29,33 +30,27 @@ class InvenTreeHomePage extends StatefulWidget { class _InvenTreeHomePageState extends State { - final GlobalKey<_InvenTreeHomePageState> _homeKey = GlobalKey<_InvenTreeHomePageState>(); - _InvenTreeHomePageState() : super() { // Initially load the profile and attempt server connection _loadProfile(); } + final GlobalKey<_InvenTreeHomePageState> _homeKey = GlobalKey<_InvenTreeHomePageState>(); + // Selected user profile UserProfile? _profile; - void _searchParts() { + void _search(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; - showSearch( - context: context, - delegate: PartSearchDelegate(context) + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SearchWidget() + ) ); - } - void _searchStock() { - if (!InvenTreeAPI().checkConnection(context)) return; - - showSearch( - context: context, - delegate: StockSearchDelegate(context) - ); } void _scan(BuildContext context) { @@ -64,31 +59,60 @@ class _InvenTreeHomePageState extends State { scanQrCode(context); } - void _parts(BuildContext context) { + void _showParts(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null))); } - void _stock(BuildContext context) { + void _showSettings(BuildContext context) { + Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeSettingsWidget())); + } + + void _showStarredParts(BuildContext context) { + if (!InvenTreeAPI().checkConnection(context)) return; + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PartList({ + "starred": "true" + }) + ) + ); + } + + void _showStock(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(null))); } - void _suppliers() { + void _showPurchaseOrders(BuildContext context) { + if (!InvenTreeAPI().checkConnection(context)) return; + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PurchaseOrderListWidget(filters: {}) + ) + ); + } + + + void _showSuppliers(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; Navigator.push(context, MaterialPageRoute(builder: (context) => CompanyListWidget(L10().suppliers, {"is_supplier": "true"}))); } - void _manufacturers() { + void _showManufacturers(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; Navigator.push(context, MaterialPageRoute(builder: (context) => CompanyListWidget(L10().manufacturers, {"is_manufacturer": "true"}))); } - void _customers() { + void _showCustomers(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; Navigator.push(context, MaterialPageRoute(builder: (context) => CompanyListWidget(L10().customers, {"is_customer": "true"}))); @@ -103,7 +127,7 @@ class _InvenTreeHomePageState extends State { }); } - void _loadProfile() async { + Future _loadProfile() async { _profile = await UserProfileDBManager().getSelectedProfile(); @@ -121,269 +145,180 @@ class _InvenTreeHomePageState extends State { setState(() {}); } - ListTile _serverTile() { - // No profile selected - // Tap to select / create a profile - if (_profile == null) { - return ListTile( - title: Text(L10().profileNotSelected), - subtitle: Text(L10().profileTapToCreate), - leading: FaIcon(FontAwesomeIcons.server), - trailing: FaIcon( - FontAwesomeIcons.user, - color: COLOR_DANGER, - ), - onTap: () { - _selectProfile(); - }, - ); + Widget _iconButton(BuildContext context, String label, IconData icon, {Function()? callback, String role = "", String permission = ""}) { + + bool connected = InvenTreeAPI().isConnected(); + + bool allowed = true; + + if (role.isNotEmpty || permission.isNotEmpty) { + allowed = InvenTreeAPI().checkPermission(role, permission); } - // Profile is selected ... - if (InvenTreeAPI().isConnecting()) { - return ListTile( - title: Text(L10().serverConnecting), - subtitle: Text("${InvenTreeAPI().baseUrl}"), - leading: FaIcon(FontAwesomeIcons.server), - trailing: Spinner( - icon: FontAwesomeIcons.spinner, - color: COLOR_PROGRESS, + return GestureDetector( + child: Card( + margin: EdgeInsets.symmetric( + vertical: 10, + horizontal: 10 ), - onTap: () { - _selectProfile(); + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FaIcon( + icon, + color: connected && allowed ? COLOR_CLICK : Colors.grey, + ), + Divider( + height: 12, + thickness: 0, + color: Colors.transparent, + ), + Text( + label, + ), + ] + ) + ), + onTap: () { + + if (!allowed) { + showSnackIcon( + L10().permissionRequired, + icon: FontAwesomeIcons.exclamationCircle, + success: false, + ); + + return; } - ); - } else if (InvenTreeAPI().isConnected()) { - return ListTile( - title: Text(L10().serverConnected), - subtitle: Text("${InvenTreeAPI().baseUrl}"), - leading: FaIcon(FontAwesomeIcons.server), - trailing: FaIcon( - FontAwesomeIcons.checkCircle, - color: COLOR_SUCCESS - ), - onTap: () { - _selectProfile(); - }, - ); - } else { - return ListTile( - title: Text(L10().serverCouldNotConnect), - subtitle: Text("${_profile!.server}"), - leading: FaIcon(FontAwesomeIcons.server), - trailing: FaIcon( - FontAwesomeIcons.timesCircle, - color: COLOR_DANGER, - ), - onTap: () { - _selectProfile(); - }, - ); - } + + if (callback != null) { + callback(); + } + + }, + ); + } + + List getGridTiles(BuildContext context) { + return [ + _iconButton( + context, + L10().scanBarcode, + FontAwesomeIcons.barcode, + callback: () { + _scan(context); + } + ), + _iconButton( + context, + L10().search, + FontAwesomeIcons.search, + callback: () { + _search(context); + } + ), + _iconButton( + context, + L10().parts, + FontAwesomeIcons.shapes, + callback: () { + _showParts(context); + } + ), + _iconButton( + context, + L10().partsStarred, + FontAwesomeIcons.solidStar, + callback: () { + _showStarredParts(context); + } + ), + _iconButton( + context, + L10().stock, + FontAwesomeIcons.boxes, + callback: () { + _showStock(context); + } + ), + _iconButton( + context, + L10().purchaseOrders, + FontAwesomeIcons.shoppingCart, + callback: () { + _showPurchaseOrders(context); + } + ), + /* + _iconButton( + context, + L10().salesOrders, + FontAwesomeIcons.truck, + ), + */ + _iconButton( + context, + L10().suppliers, + FontAwesomeIcons.building, + callback: () { + _showSuppliers(context); + } + ), + _iconButton( + context, + L10().manufacturers, + FontAwesomeIcons.industry, + callback: () { + _showManufacturers(context); + } + ), + _iconButton( + context, + L10().customers, + FontAwesomeIcons.userTie, + callback: () { + _showCustomers(context); + } + ), + _iconButton( + context, + L10().settings, + FontAwesomeIcons.cogs, + callback: () { + _showSettings(context); + } + ) + ]; } @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. return Scaffold( key: _homeKey, appBar: AppBar( title: Text(L10().appTitle), - actions: [ - /* + actions: [ IconButton( - icon: FaIcon(FontAwesomeIcons.search), - tooltip: L10().search, - onPressed: _searchParts, - ), - */ + icon: FaIcon( + FontAwesomeIcons.server, + color: InvenTreeAPI().isConnected() ? COLOR_SUCCESS : COLOR_DANGER, + ), + onPressed: _selectProfile, + ) ], ), - drawer: new InvenTreeDrawer(context), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: ([ - Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - IconButton( - icon: new FaIcon(FontAwesomeIcons.barcode), - tooltip: L10().scanBarcode, - onPressed: () { _scan(context); }, - ), - Text(L10().scanBarcode), - ], - ), - ], - ), - Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - IconButton( - icon: new FaIcon(FontAwesomeIcons.shapes), - tooltip: L10().parts, - onPressed: () { _parts(context); }, - ), - Text(L10().parts), - ], - ), - Column( - children: [ - - IconButton( - icon: new FaIcon(FontAwesomeIcons.search), - tooltip: L10().searchParts, - onPressed: _searchParts, - ), - Text(L10().searchParts), - ], - ), - // TODO - Re-add starred parts link - /* - Column( - children: [ - IconButton( - icon: FaIcon(FontAwesomeIcons.solidStar), - onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => StarredPartWidget())); - }, - ), - Text("Starred Parts"), - ] - ), - */ - ], - ), - Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - IconButton( - icon: new FaIcon(FontAwesomeIcons.boxes), - tooltip: L10().stock, - onPressed: () { _stock(context); }, - ), - Text(L10().stock), - ], - ), - Column( - children: [ - IconButton( - icon: new FaIcon(FontAwesomeIcons.search), - tooltip: L10().searchStock, - onPressed: _searchStock, - ), - Text(L10().searchStock), - ], - ), - ] - ), - Spacer(), - // TODO - Re-add these when the features actually do something.. - /* - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - IconButton( - icon: new FaIcon(FontAwesomeIcons.building), - tooltip: "Suppliers", - onPressed: _suppliers, - ), - Text("Suppliers"), - ], - ), - Column( - children: [ - IconButton( - icon: FaIcon(FontAwesomeIcons.industry), - tooltip: "Manufacturers", - onPressed: _manufacturers, - ), - Text("Manufacturers") - ], - ), - Column( - children: [ - IconButton( - icon: FaIcon(FontAwesomeIcons.userTie), - tooltip: "Customers", - onPressed: _customers, - ), - Text("Customers"), - ] - ) - ], - ), - Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - IconButton( - icon: new FaIcon(FontAwesomeIcons.tools), - tooltip: "Build", - onPressed: _unsupported, - ), - Text("Build"), - ], - ), - Column( - children: [ - IconButton( - icon: new FaIcon(FontAwesomeIcons.shoppingCart), - tooltip: "Order", - onPressed: _unsupported, - ), - Text("Order"), - ] - ), - Column( - children: [ - IconButton( - icon: new FaIcon(FontAwesomeIcons.truck), - tooltip: "Ship", - onPressed: _unsupported, - ), - Text("Ship"), - ] - ) - ], - ), - Spacer(), - */ - Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: _serverTile(), - ), - ], - ), - ]), - ), + drawer: InvenTreeDrawer(context), + body: ListView( + children: [ + GridView.extent( + maxCrossAxisExtent: 140, + shrinkWrap: true, + physics: ClampingScrollPhysics(), + children: getGridTiles(context), + ), + ], ), ); } diff --git a/lib/widget/location_display.dart b/lib/widget/location_display.dart index 26e8f3b6..138d3f04 100644 --- a/lib/widget/location_display.dart +++ b/lib/widget/location_display.dart @@ -1,21 +1,19 @@ -import 'package:inventree/api.dart'; -import 'package:inventree/app_colors.dart'; -import 'package:inventree/app_settings.dart'; -import 'package:inventree/barcode.dart'; -import 'package:inventree/inventree/sentry.dart'; -import 'package:inventree/inventree/stock.dart'; -import 'package:inventree/widget/progress.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter/foundation.dart"; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:inventree/widget/stock_detail.dart'; -import 'package:inventree/widget/paginator.dart'; -import 'package:inventree/l10.dart'; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/barcode.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/widget/progress.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/widget/stock_detail.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/widget/stock_list.dart"; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; class LocationDisplayWidget extends StatefulWidget { @@ -31,6 +29,8 @@ class LocationDisplayWidget extends StatefulWidget { class _LocationDisplayState extends RefreshableState { + _LocationDisplayState(this.location); + final InvenTreeStockLocation? location; @override @@ -62,7 +62,7 @@ class _LocationDisplayState extends RefreshableState { ); */ - if ((location != null) && (InvenTreeAPI().checkPermission('stock_location', 'change'))) { + if ((location != null) && (InvenTreeAPI().checkPermission("stock_location", "change"))) { actions.add( IconButton( icon: FaIcon(FontAwesomeIcons.edit), @@ -92,11 +92,9 @@ class _LocationDisplayState extends RefreshableState { ); } - _LocationDisplayState(this.location); - List _sublocations = []; - String _locationFilter = ''; + String _locationFilter = ""; List get sublocations { @@ -146,7 +144,10 @@ class _LocationDisplayState extends RefreshableState { data: { "parent": (pk > 0) ? pk : null, }, - onSuccess: (data) async { + onSuccess: (result) async { + + Map data = result as Map; + if (data.containsKey("pk")) { var loc = InvenTreeStockLocation.fromJson(data); @@ -175,7 +176,10 @@ class _LocationDisplayState extends RefreshableState { data: { "location": pk, }, - onSuccess: (data) async { + onSuccess: (result) async { + + Map data = result as Map; + if (data.containsKey("pk")) { var item = InvenTreeStockItem.fromJson(data); @@ -280,7 +284,7 @@ class _LocationDisplayState extends RefreshableState { children: detailTiles(), ); case 1: - return PaginatedStockList(filters); + return PaginatedStockItemList(filters); case 2: return ListView( children: ListTile.divideTiles( @@ -307,13 +311,13 @@ List detailTiles() { L10().sublocations, style: TextStyle(fontWeight: FontWeight.bold), ), - trailing: sublocations.length > 0 ? Text("${sublocations.length}") : null, + trailing: sublocations.isNotEmpty ? Text("${sublocations.length}") : null, ), ]; if (loading) { tiles.add(progressIndicator()); - } else if (_sublocations.length > 0) { + } else if (_sublocations.isNotEmpty) { tiles.add(SublocationList(_sublocations)); } else { tiles.add(ListTile( @@ -334,7 +338,7 @@ List detailTiles() { tiles.add(locationDescriptionCard(includeActions: false)); - if (InvenTreeAPI().checkPermission('stock', 'add')) { + if (InvenTreeAPI().checkPermission("stock", "add")) { tiles.add( ListTile( @@ -362,7 +366,7 @@ List detailTiles() { if (location != null) { // Stock adjustment actions - if (InvenTreeAPI().checkPermission('stock', 'change')) { + if (InvenTreeAPI().checkPermission("stock", "change")) { // Scan items into location tiles.add( ListTile( @@ -422,9 +426,10 @@ List detailTiles() { class SublocationList extends StatelessWidget { - final List _locations; - SublocationList(this._locations); + const SublocationList(this._locations); + + final List _locations; void _openLocation(BuildContext context, int pk) { @@ -440,7 +445,7 @@ class SublocationList extends StatelessWidget { InvenTreeStockLocation loc = _locations[index]; return ListTile( - title: Text('${loc.name}'), + title: Text("${loc.name}"), subtitle: Text("${loc.description}"), trailing: Text("${loc.itemcount}"), onTap: () { @@ -460,162 +465,3 @@ class SublocationList extends StatelessWidget { ); } } - -/* - * Widget for displaying a list of stock items within a stock location. - * - * Users server-side pagination for snappy results - */ - -class PaginatedStockList extends StatefulWidget { - - final Map filters; - - PaginatedStockList(this.filters); - - @override - _PaginatedStockListState createState() => _PaginatedStockListState(filters); -} - - -class _PaginatedStockListState extends State { - - static const _pageSize = 25; - - String _searchTerm = ""; - - final Map filters; - - _PaginatedStockListState(this.filters); - - final PagingController _pagingController = PagingController(firstPageKey: 0); - - @override - void initState() { - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); - - super.initState(); - } - - @override - void dispose() { - _pagingController.dispose(); - super.dispose(); - } - - int resultCount = 0; - - Future _fetchPage(int pageKey) async { - try { - - Map params = this.filters; - - params["search"] = "${_searchTerm}"; - - // Do we include stock items from sub-locations? - final bool cascade = await InvenTreeSettingsManager().getValue("stockSublocation", true); - params["cascade"] = "${cascade}"; - - final page = await InvenTreeStockItem().listPaginated(_pageSize, pageKey, filters: params); - - int pageLength = page?.length ?? 0; - int pageCount = page?.count ?? 0; - - final isLastPage = pageLength < _pageSize; - - // Construct a list of stock item objects - List items = []; - - if (page != null) { - for (var result in page.results) { - if (result is InvenTreeStockItem) { - items.add(result); - } - } - } - - if (isLastPage) { - _pagingController.appendLastPage(items); - } else { - final int nextPageKey = pageKey + pageLength; - _pagingController.appendPage(items, nextPageKey); - } - - setState(() { - resultCount = pageCount; - }); - - } catch (error, stackTrace) { - _pagingController.error = error; - - sentryReportError(error, stackTrace); - } - } - - void _openItem(BuildContext context, int pk) { - InvenTreeStockItem().get(pk).then((var item) { - if (item is InvenTreeStockItem) { - Navigator.push(context, MaterialPageRoute(builder: (context) => StockDetailWidget(item))); - } - }); - } - - Widget _buildItem(BuildContext context, InvenTreeStockItem item) { - return ListTile( - title: Text("${item.partName}"), - subtitle: Text("${item.locationPathString}"), - leading: InvenTreeAPI().getImage( - item.partThumbnail, - width: 40, - height: 40, - ), - trailing: Text("${item.displayQuantity}", - style: TextStyle(fontWeight: FontWeight.bold), - ), - onTap: () { - _openItem(context, item.pk); - }, - ); - } - - final TextEditingController searchController = TextEditingController(); - - void updateSearchTerm() { - _searchTerm = searchController.text; - _pagingController.refresh(); - } - - @override - Widget build (BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - PaginatedSearchWidget(searchController, updateSearchTerm, resultCount), - Expanded( - child: CustomScrollView( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - scrollDirection: Axis.vertical, - slivers: [ - // TODO - Search input - PagedSliverList.separated( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) { - return _buildItem(context, item); - }, - noItemsFoundIndicatorBuilder: (context) { - return NoResultsWidget("No stock items found"); - } - ), - separatorBuilder: (context, item) => const Divider(height: 1), - ) - ] - ) - ) - ] - ); - } -} diff --git a/lib/widget/location_list.dart b/lib/widget/location_list.dart new file mode 100644 index 00000000..b766ca08 --- /dev/null +++ b/lib/widget/location_list.dart @@ -0,0 +1,82 @@ +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; + +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/widget/location_display.dart"; +import "package:inventree/widget/paginator.dart"; + +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/l10.dart"; + + +class StockLocationList extends StatefulWidget { + + const StockLocationList(this.filters); + + final Map filters; + + @override + _StockLocationListState createState() => _StockLocationListState(filters); +} + + +class _StockLocationListState extends RefreshableState { + + _StockLocationListState(this.filters); + + final Map filters; + + @override + String getAppBarTitle(BuildContext context) => L10().stockLocations; + + @override + Widget getBody(BuildContext context) { + return PaginatedStockLocationList(filters); + } +} + + +class PaginatedStockLocationList extends StatefulWidget { + + const PaginatedStockLocationList(this.filters); + + final Map filters; + + @override + _PaginatedStockLocationListState createState() => _PaginatedStockLocationListState(filters); +} + + +class _PaginatedStockLocationListState extends PaginatedSearchState { + + _PaginatedStockLocationListState(Map filters) : super(filters); + + @override + Future requestPage(int limit, int offset, Map params) async { + + final page = await InvenTreeStockLocation().listPaginated(limit, offset, filters: params); + + return page; + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + + InvenTreeStockLocation location = model as InvenTreeStockLocation; + + return ListTile( + title: Text(location.name), + subtitle: Text(location.pathstring), + trailing: Text("${location.itemcount}"), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LocationDisplayWidget(location) + ) + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widget/paginator.dart b/lib/widget/paginator.dart index 84641330..9c6cfdec 100644 --- a/lib/widget/paginator.dart +++ b/lib/widget/paginator.dart @@ -1,19 +1,158 @@ -// Pagination related widgets +import "package:flutter/material.dart"; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/l10.dart'; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:infinite_scroll_pagination/infinite_scroll_pagination.dart"; + +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/sentry.dart"; +import "package:inventree/l10.dart"; + + +class PaginatedSearchState extends State { + + PaginatedSearchState(this.filters); + + final Map filters; + + static const _pageSize = 25; + + // Search query term + String searchTerm = ""; + + int resultCount = 0; + + // Text controller + final TextEditingController searchController = TextEditingController(); + + // Pagination controller + final PagingController _pagingController = PagingController(firstPageKey: 0); + + @override + void initState() { + _pagingController.addPageRequestListener((pageKey) { + _fetchPage(pageKey); + }); + + super.initState(); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } + + Future requestPage(int limit, int offset, Map params) async { + + print("Blank request page"); + // Default implementation returns null - must be overridden + return null; + } + + Future _fetchPage(int pageKey) async { + try { + Map params = filters; + + params["search"] = "${searchTerm}"; + + final page = await requestPage( + _pageSize, + pageKey, + params + ); + + int pageLength = page?.length ?? 0; + int pageCount = page?.count ?? 0; + + final isLastPage = pageLength < _pageSize; + + List items = []; + + if (page != null) { + for (var result in page.results) { + if (result is InvenTreeModel) { + items.add(result); + } + } + } + + if (isLastPage) { + _pagingController.appendLastPage(items); + } else { + final int nextPageKey = pageKey + pageLength; + _pagingController.appendPage(items, nextPageKey); + } + + setState(() { + resultCount = pageCount; + }); + } catch (error, stackTrace) { + _pagingController.error = error; + + sentryReportError(error, stackTrace); + } + } + + void updateSearchTerm() { + searchTerm = searchController.text; + _pagingController.refresh(); + } + + Widget buildItem(BuildContext context, InvenTreeModel item) { + + // This method must be overridden by the child class + return ListTile( + title: Text("*** UNIMPLEMENTED ***"), + subtitle: Text("*** buildItem() is unimplemented for this widget!"), + ); + } + + String get noResultsText => L10().noResults; + + @override + Widget build (BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + PaginatedSearchWidget(searchController, updateSearchTerm, resultCount), + Expanded( + child: CustomScrollView( + shrinkWrap: true, + physics: ClampingScrollPhysics(), + scrollDirection: Axis.vertical, + slivers: [ + // TODO - Search input + PagedSliverList.separated( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + return buildItem(context, item); + }, + noItemsFoundIndicatorBuilder: (context) { + return NoResultsWidget(noResultsText); + } + ), + separatorBuilder: (context, item) => const Divider(height: 1), + ) + ] + ) + ) + ] + ); + } + +} class PaginatedSearchWidget extends StatelessWidget { - Function onChanged; + const PaginatedSearchWidget(this.controller, this.onChanged, this.results); - int results = 0; + final Function onChanged; - TextEditingController controller; + final int results; - PaginatedSearchWidget(this.controller, this.onChanged, this.results); + final TextEditingController controller; @override Widget build(BuildContext context) { @@ -44,9 +183,9 @@ class PaginatedSearchWidget extends StatelessWidget { class NoResultsWidget extends StatelessWidget { - final String description; + const NoResultsWidget(this.description); - NoResultsWidget(this.description); + final String description; @override Widget build(BuildContext context) { diff --git a/lib/widget/part_attachments_widget.dart b/lib/widget/part_attachments_widget.dart index 99955e64..2e14141f 100644 --- a/lib/widget/part_attachments_widget.dart +++ b/lib/widget/part_attachments_widget.dart @@ -1,23 +1,19 @@ +import "dart:io"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/widget/fields.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/widget/snacks.dart"; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/widget/fields.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:inventree/widget/snacks.dart'; - -import 'dart:io'; - -import '../api.dart'; -import '../l10.dart'; +import "package:inventree/api.dart"; +import "package:inventree/l10.dart"; class PartAttachmentsWidget extends StatefulWidget { - PartAttachmentsWidget(this.part, {Key? key}) : super(key: key); + const PartAttachmentsWidget(this.part, {Key? key}) : super(key: key); final InvenTreePart part; @@ -42,7 +38,7 @@ class _PartAttachmentDisplayState extends RefreshableState actions = []; - if (InvenTreeAPI().checkPermission('part', 'change')) { + if (InvenTreeAPI().checkPermission("part", "change")) { // File upload actions.add( @@ -127,7 +123,7 @@ class _PartAttachmentDisplayState extends RefreshableState { - InvenTreePart part; - _PartDisplayState(this.part); + InvenTreePart part; + @override String getAppBarTitle(BuildContext context) => L10().partDetails; @@ -46,7 +46,7 @@ class _PartDisplayState extends RefreshableState { List actions = []; - if (InvenTreeAPI().checkPermission('part', 'view')) { + if (InvenTreeAPI().checkPermission("part", "view")) { actions.add( IconButton( icon: FaIcon(FontAwesomeIcons.globe), @@ -55,7 +55,7 @@ class _PartDisplayState extends RefreshableState { ); } - if (InvenTreeAPI().checkPermission('part', 'change')) { + if (InvenTreeAPI().checkPermission("part", "change")) { actions.add( IconButton( icon: FaIcon(FontAwesomeIcons.edit), @@ -89,9 +89,9 @@ class _PartDisplayState extends RefreshableState { await part.getTestTemplates(); } - void _toggleStar() async { + Future _toggleStar() async { - if (InvenTreeAPI().checkPermission('part', 'view')) { + if (InvenTreeAPI().checkPermission("part", "view")) { await part.update(values: {"starred": "${!part.starred}"}); refresh(); } @@ -327,7 +327,8 @@ class _PartDisplayState extends RefreshableState { } // TODO - Add request tests? - if (false && part.isTrackable) { + /* + if (part.isTrackable) { tiles.add(ListTile( title: Text(L10().testsRequired), leading: FaIcon(FontAwesomeIcons.tasks), @@ -336,6 +337,7 @@ class _PartDisplayState extends RefreshableState { ) ); } + */ // Notes field tiles.add( @@ -398,6 +400,12 @@ class _PartDisplayState extends RefreshableState { fields["part"]["hidden"] = true; + int? default_location = part.defaultLocation; + + if (default_location != null) { + fields["location"]["value"] = default_location; + } + InvenTreeStockItem().createForm( context, L10().stockItemCreate, @@ -405,7 +413,10 @@ class _PartDisplayState extends RefreshableState { data: { "part": "${part.pk}", }, - onSuccess: (data) async { + onSuccess: (result) async { + + Map data = result as Map; + if (data.containsKey("pk")) { var item = InvenTreeStockItem.fromJson(data); @@ -437,20 +448,22 @@ class _PartDisplayState extends RefreshableState { ); // TODO - Add this action back in once implemented - if (false) { - tiles.add( - ListTile( - title: Text(L10().barcodeScanItem), - leading: FaIcon(FontAwesomeIcons.box), - trailing: FaIcon(FontAwesomeIcons.qrcode), - onTap: () { - // TODO - }, - ), - ); - } - - if (false && !part.isActive && InvenTreeAPI().checkPermission('part', 'delete')) { + /* + tiles.add( + ListTile( + title: Text(L10().barcodeScanItem), + leading: FaIcon(FontAwesomeIcons.box), + trailing: FaIcon(FontAwesomeIcons.qrcode), + onTap: () { + // TODO + }, + ), + ); + */ + + /* + // TODO: Implement part deletion + if (!part.isActive && InvenTreeAPI().checkPermission("part", "delete")) { tiles.add( ListTile( title: Text(L10().deletePart), @@ -462,6 +475,7 @@ class _PartDisplayState extends RefreshableState { ) ); } + */ return tiles; } @@ -480,7 +494,9 @@ class _PartDisplayState extends RefreshableState { ), ); case 1: - return PaginatedStockList({"part": "${part.pk}"}); + return PaginatedStockItemList( + {"part": "${part.pk}"} + ); case 2: return Center( child: ListView( diff --git a/lib/widget/part_image_widget.dart b/lib/widget/part_image_widget.dart index 533072a4..63981371 100644 --- a/lib/widget/part_image_widget.dart +++ b/lib/widget/part_image_widget.dart @@ -1,23 +1,21 @@ -import 'dart:io'; +import "dart:io"; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; -import 'package:inventree/api.dart'; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/widget/fields.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:inventree/widget/snacks.dart'; - -import '../l10.dart'; +import "package:inventree/api.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/widget/fields.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/widget/snacks.dart"; +import "package:inventree/l10.dart"; class PartImageWidget extends StatefulWidget { - PartImageWidget(this.part, {Key? key}) : super(key: key); + const PartImageWidget(this.part, {Key? key}) : super(key: key); final InvenTreePart part; @@ -46,7 +44,7 @@ class _PartImageState extends RefreshableState { List actions = []; - if (InvenTreeAPI().checkPermission('part', 'change')) { + if (InvenTreeAPI().checkPermission("part", "change")) { // File upload actions.add( diff --git a/lib/widget/part_list.dart b/lib/widget/part_list.dart new file mode 100644 index 00000000..a6fb6fef --- /dev/null +++ b/lib/widget/part_list.dart @@ -0,0 +1,100 @@ +import "package:flutter/material.dart"; + +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/part_detail.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/api.dart"; +import "package:inventree/app_settings.dart"; +import "package:inventree/l10.dart"; + + +class PartList extends StatefulWidget { + + const PartList(this.filters); + + final Map filters; + + @override + _PartListState createState() => _PartListState(filters); +} + + +class _PartListState extends RefreshableState { + + _PartListState(this.filters); + + final Map filters; + + @override + String getAppBarTitle(BuildContext context) => L10().parts; + + @override + Widget getBody(BuildContext context) { + return PaginatedPartList(filters); + } + +} + + +class PaginatedPartList extends StatefulWidget { + + const PaginatedPartList(this.filters, {this.onTotalChanged}); + + final Map filters; + + final Function(int)? onTotalChanged; + + @override + _PaginatedPartListState createState() => _PaginatedPartListState(filters, onTotalChanged); +} + + +class _PaginatedPartListState extends PaginatedSearchState { + + _PaginatedPartListState(Map filters, this.onTotalChanged) : super(filters); + + Function(int)? onTotalChanged; + + @override + Future requestPage(int limit, int offset, Map params) async { + final bool cascade = await InvenTreeSettingsManager().getBool("partSubcategory", true); + + params["cascade"] = "${cascade}"; + + final page = await InvenTreePart().listPaginated(limit, offset, filters: params); + + return page; + } + + void _openPart(BuildContext context, int pk) { + // Attempt to load the part information + InvenTreePart().get(pk).then((var part) { + if (part is InvenTreePart) { + + Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part))); + } + }); + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + + InvenTreePart part = model as InvenTreePart; + + return ListTile( + title: Text(part.fullname), + subtitle: Text("${part.description}"), + trailing: Text("${part.inStockString}"), + leading: InvenTreeAPI().getImage( + part.thumbnail, + width: 40, + height: 40, + ), + onTap: () { + _openPart(context, part.pk); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widget/part_notes.dart b/lib/widget/part_notes.dart index 484627a0..e2618d2f 100644 --- a/lib/widget/part_notes.dart +++ b/lib/widget/part_notes.dart @@ -1,18 +1,18 @@ -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/api.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:inventree/l10.dart'; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/api.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter_markdown/flutter_markdown.dart"; +import "package:inventree/l10.dart"; class PartNotesWidget extends StatefulWidget { - final InvenTreePart part; + const PartNotesWidget(this.part, {Key? key}) : super(key: key); - PartNotesWidget(this.part, {Key? key}) : super(key: key); + final InvenTreePart part; @override _PartNotesState createState() => _PartNotesState(part); @@ -21,10 +21,10 @@ class PartNotesWidget extends StatefulWidget { class _PartNotesState extends RefreshableState { - final InvenTreePart part; - _PartNotesState(this.part); + final InvenTreePart part; + @override Future request() async { await part.reload(); @@ -38,7 +38,7 @@ class _PartNotesState extends RefreshableState { List actions = []; - if (InvenTreeAPI().checkPermission('part', 'change')) { + if (InvenTreeAPI().checkPermission("part", "change")) { actions.add( IconButton( icon: FaIcon(FontAwesomeIcons.edit), diff --git a/lib/widget/part_suppliers.dart b/lib/widget/part_suppliers.dart index c7a06fc5..fc1b4030 100644 --- a/lib/widget/part_suppliers.dart +++ b/lib/widget/part_suppliers.dart @@ -1,19 +1,19 @@ -import 'package:inventree/l10.dart'; +import "dart:core"; -import 'package:inventree/api.dart'; +import "package:inventree/l10.dart"; -import 'dart:core'; +import "package:inventree/api.dart"; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/inventree/company.dart'; -import 'package:inventree/widget/company_detail.dart'; -import 'package:inventree/widget/refreshable_state.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/inventree/company.dart"; +import "package:inventree/widget/company_detail.dart"; +import "package:inventree/widget/refreshable_state.dart"; class PartSupplierWidget extends StatefulWidget { - PartSupplierWidget(this.part, {Key? key}) : super(key: key); + const PartSupplierWidget(this.part, {Key? key}) : super(key: key); final InvenTreePart part; diff --git a/lib/widget/progress.dart b/lib/widget/progress.dart index 9aaeb256..92ef69c6 100644 --- a/lib/widget/progress.dart +++ b/lib/widget/progress.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart'; +import "package:flutter/material.dart"; /* * Construct a circular progress indicator diff --git a/lib/widget/purchase_order_detail.dart b/lib/widget/purchase_order_detail.dart new file mode 100644 index 00000000..1558ddbc --- /dev/null +++ b/lib/widget/purchase_order_detail.dart @@ -0,0 +1,384 @@ +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; + +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:one_context/one_context.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/api_form.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/helpers.dart"; +import "package:inventree/inventree/company.dart"; +import "package:inventree/inventree/purchase_order.dart"; +import "package:inventree/widget/company_detail.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/widget/snacks.dart"; +import "package:inventree/widget/stock_list.dart"; + + +class PurchaseOrderDetailWidget extends StatefulWidget { + + const PurchaseOrderDetailWidget(this.order, {Key? key}): super(key: key); + + final InvenTreePurchaseOrder order; + + @override + _PurchaseOrderDetailState createState() => _PurchaseOrderDetailState(order); +} + + +class _PurchaseOrderDetailState extends RefreshableState { + + _PurchaseOrderDetailState(this.order); + + final InvenTreePurchaseOrder order; + + List lines = []; + + int completedLines = 0; + + @override + String getAppBarTitle(BuildContext context) => L10().purchaseOrder; + + @override + List getAppBarActions(BuildContext context) { + List actions = []; + + if (InvenTreeAPI().checkPermission("purchase_order", "change")) { + actions.add( + IconButton( + icon: FaIcon(FontAwesomeIcons.edit), + tooltip: L10().edit, + onPressed: () { + editOrder(context); + } + ) + ); + } + + return actions; + } + + @override + Future request() async { + await order.reload(); + + lines = await order.getLineItems(); + + completedLines = 0; + + for (var line in lines) { + if (line.isComplete) { + completedLines += 1; + } + } + + } + + Future editOrder(BuildContext context) async { + + order.editForm( + context, + L10().purchaseOrderEdit, + onSuccess: (data) async { + refresh(); + } + ); + } + + Widget headerTile(BuildContext context) { + + InvenTreeCompany? supplier = order.supplier; + + return Card( + child: ListTile( + title: Text(order.reference), + subtitle: Text(order.description), + leading: supplier == null ? null : InvenTreeAPI().getImage(supplier.thumbnail, width: 40, height: 40), + ) + ); + + } + + List orderTiles(BuildContext context) { + + List tiles = []; + + InvenTreeCompany? supplier = order.supplier; + + tiles.add(headerTile(context)); + + if (supplier != null) { + tiles.add(ListTile( + title: Text(L10().supplier), + subtitle: Text(supplier.name), + leading: FaIcon(FontAwesomeIcons.building, color: COLOR_CLICK), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CompanyDetailWidget(supplier) + ) + ); + }, + )); + } + + if (order.supplierReference.isNotEmpty) { + tiles.add(ListTile( + title: Text(L10().supplierReference), + subtitle: Text(order.supplierReference), + leading: FaIcon(FontAwesomeIcons.hashtag), + )); + } + + tiles.add(ListTile( + title: Text(L10().lineItems), + leading: FaIcon(FontAwesomeIcons.clipboardList, color: COLOR_CLICK), + trailing: Text("${order.lineItemCount}"), + onTap: () { + setState(() { + // Switch to the "line items" tab + tabIndex = 1; + }); + }, + )); + + tiles.add(ListTile( + title: Text(L10().received), + leading: FaIcon(FontAwesomeIcons.clipboardCheck, color: COLOR_CLICK), + trailing: Text("${completedLines}"), + onTap: () { + setState(() { + // Switch to the "received items" tab + tabIndex = 2; + }); + }, + )); + + if (order.issueDate.isNotEmpty) { + tiles.add(ListTile( + title: Text(L10().issueDate), + subtitle: Text(order.issueDate), + leading: FaIcon(FontAwesomeIcons.calendarAlt), + )); + } + + if (order.targetDate.isNotEmpty) { + tiles.add(ListTile( + title: Text(L10().targetDate), + subtitle: Text(order.targetDate), + leading: FaIcon(FontAwesomeIcons.calendarAlt), + )); + } + + return tiles; + + } + + void receiveLine(BuildContext context, InvenTreePOLineItem lineItem) { + + Map fields = { + "line_item": { + "parent": "items", + "nested": true, + "hidden": true, + "value": lineItem.pk, + }, + "quantity": { + "parent": "items", + "nested": true, + "value": lineItem.outstanding, + }, + "status": { + "parent": "items", + "nested": true, + }, + "location": { + }, + "barcode": { + "parent": "items", + "nested": true, + "type": "barcode", + "label": L10().barcodeAssign, + "required": false, + } + }; + + // TODO: Pre-fill the "location" value if the part has a default location specified + + launchApiForm( + context, + L10().receiveItem, + order.receive_url, + fields, + method: "POST", + icon: FontAwesomeIcons.signInAlt, + onSuccess: (data) async { + showSnackIcon(L10().receivedItem, success: true); + refresh(); + } + ); + } + + void lineItemMenu(BuildContext context, InvenTreePOLineItem lineItem) { + + List children = []; + + children.add( + SimpleDialogOption( + onPressed: () { + OneContext().popDialog(); + + // TODO: Navigate to the "SupplierPart" display? + }, + child: ListTile( + title: Text(L10().viewSupplierPart), + leading: FaIcon(FontAwesomeIcons.eye), + ) + ) + ); + + if (order.isPlaced && InvenTreeAPI().supportPoReceive()) { + children.add( + SimpleDialogOption( + onPressed: () { + // Hide the dialog option + OneContext().popDialog(); + + receiveLine(context, lineItem); + }, + child: ListTile( + title: Text(L10().receiveItem), + leading: FaIcon(FontAwesomeIcons.signInAlt), + ) + ) + ); + } + + // No valid actions available + if (children.isEmpty) { + return; + } + + children.insert(0, Divider()); + + showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(L10().lineItem), + children: children, + ); + } + ); + + } + + List lineTiles(BuildContext context) { + + List tiles = []; + + tiles.add(headerTile(context)); + + for (var line in lines) { + + InvenTreeSupplierPart? supplierPart = line.supplierPart; + + if (supplierPart != null) { + + String q = simpleNumberString(line.quantity); + + Color c = Colors.black; + + if (order.isOpen) { + + q = simpleNumberString(line.received) + " / " + simpleNumberString(line.quantity); + + if (line.isComplete) { + c = COLOR_SUCCESS; + } else { + c = COLOR_DANGER; + } + } + + tiles.add( + ListTile( + title: Text(supplierPart.SKU), + subtitle: Text(supplierPart.partName), + leading: InvenTreeAPI().getImage(supplierPart.partImage, width: 40, height: 40), + trailing: Text( + q, + style: TextStyle( + color: c, + ), + ), + onTap: () { + // TODO: ? + }, + onLongPress: () { + lineItemMenu(context, line); + }, + ) + ); + } + } + + return tiles; + } + + @override + Widget getBody(BuildContext context) { + + return Center( + child: getSelectedWidget(context, tabIndex), + ); + } + + Widget getSelectedWidget(BuildContext context, int index) { + switch (index) { + case 0: + return ListView( + children: orderTiles(context) + ); + case 1: + return ListView( + children: lineTiles(context) + ); + case 2: + // Stock items received against this order + Map filters = { + "purchase_order": "${order.pk}" + }; + + return PaginatedStockItemList(filters); + + default: + return ListView(); + } + } + + @override + Widget getBottomNavBar(BuildContext context) { + return BottomNavigationBar( + currentIndex: tabIndex, + onTap: onTabSelectionChanged, + items: [ + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.info), + label: L10().details + ), + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.thList), + label: L10().lineItems, + ), + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.boxes), + label: L10().stockItems + ) + ], + ); + } + +} \ No newline at end of file diff --git a/lib/widget/purchase_order_list.dart b/lib/widget/purchase_order_list.dart new file mode 100644 index 00000000..d1a107c9 --- /dev/null +++ b/lib/widget/purchase_order_list.dart @@ -0,0 +1,96 @@ +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; + +import "package:inventree/inventree/company.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/purchase_order_detail.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/api.dart"; +import "package:inventree/inventree/purchase_order.dart"; + +/* + * Widget class for displaying a list of Purchase Orders + */ +class PurchaseOrderListWidget extends StatefulWidget { + + const PurchaseOrderListWidget({this.filters = const {}, Key? key}) : super(key: key); + + final Map filters; + + @override + _PurchaseOrderListWidgetState createState() => _PurchaseOrderListWidgetState(filters); +} + + +class _PurchaseOrderListWidgetState extends RefreshableState { + + _PurchaseOrderListWidgetState(this.filters); + + final Map filters; + + @override + String getAppBarTitle(BuildContext context) => L10().purchaseOrders; + + @override + Widget getBody(BuildContext context) { + return PaginatedPurchaseOrderList(filters); + } +} + + +class PaginatedPurchaseOrderList extends StatefulWidget { + + const PaginatedPurchaseOrderList(this.filters); + + final Map filters; + + @override + _PaginatedPurchaseOrderListState createState() => _PaginatedPurchaseOrderListState(filters); + +} + + +class _PaginatedPurchaseOrderListState extends PaginatedSearchState { + + _PaginatedPurchaseOrderListState(Map filters) : super(filters); + + @override + Future requestPage(int limit, int offset, Map params) async { + + params["outstanding"] = "true"; + + final page = await InvenTreePurchaseOrder().listPaginated(limit, offset, filters: params); + + return page; + + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + + InvenTreePurchaseOrder order = model as InvenTreePurchaseOrder; + + InvenTreeCompany? supplier = order.supplier; + + return ListTile( + title: Text(order.reference), + subtitle: Text(order.description), + leading: supplier == null ? null : InvenTreeAPI().getImage( + supplier.thumbnail, + width: 40, + height: 40, + ), + trailing: Text("${order.lineItemCount}"), + onTap: () async { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PurchaseOrderDetailWidget(order) + ) + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widget/refreshable_state.dart b/lib/widget/refreshable_state.dart index aa288499..f7198ed6 100644 --- a/lib/widget/refreshable_state.dart +++ b/lib/widget/refreshable_state.dart @@ -1,7 +1,8 @@ -import 'package:inventree/widget/drawer.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; +import "package:inventree/widget/back.dart"; +import "package:inventree/widget/drawer.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; abstract class RefreshableState extends State { @@ -9,7 +10,7 @@ abstract class RefreshableState extends State { final refreshableKey = GlobalKey(); // Storage for context once "Build" is called - BuildContext? _context; + late BuildContext? _context; // Current tab index (used for widgets which display bottom tabs) int tabIndex = 0; @@ -32,6 +33,7 @@ abstract class RefreshableState extends State { String getAppBarTitle(BuildContext context) { return "App Bar Title"; } + @override void initState() { super.initState(); WidgetsBinding.instance?.addPostFrameCallback((_) => onBuild(_context!)); @@ -60,14 +62,6 @@ abstract class RefreshableState extends State { }); } - // Function to construct an appbar (override if needed) - AppBar getAppBar(BuildContext context) { - return AppBar( - title: Text(getAppBarTitle(context)), - actions: getAppBarActions(context), - ); - } - // Function to construct a drawer (override if needed) Widget getDrawer(BuildContext context) { return InvenTreeDrawer(context); @@ -96,8 +90,12 @@ abstract class RefreshableState extends State { return Scaffold( key: refreshableKey, - appBar: getAppBar(context), - drawer: null, + appBar: AppBar( + title: Text(getAppBarTitle(context)), + actions: getAppBarActions(context), + leading: backButton(context, refreshableKey), + ), + drawer: getDrawer(context), floatingActionButton: getFab(context), body: Builder( builder: (BuildContext context) { diff --git a/lib/widget/search.dart b/lib/widget/search.dart index d010c1df..387eb8f3 100644 --- a/lib/widget/search.dart +++ b/lib/widget/search.dart @@ -1,393 +1,347 @@ +import "dart:async"; -import 'package:inventree/widget/part_detail.dart'; -import 'package:inventree/widget/progress.dart'; -import 'package:inventree/widget/snacks.dart'; -import 'package:inventree/widget/stock_detail.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/l10.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/inventree/stock.dart'; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; -import '../api.dart'; +import "package:inventree/inventree/company.dart"; +import "package:inventree/inventree/purchase_order.dart"; +import "package:inventree/widget/part_list.dart"; +import "package:inventree/widget/purchase_order_list.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/widget/stock_list.dart"; +import "package:inventree/widget/category_list.dart"; +import "package:inventree/widget/company_list.dart"; +import "package:inventree/widget/location_list.dart"; -// TODO - Refactor duplicate code in this file! -class PartSearchDelegate extends SearchDelegate { - - final partSearchKey = GlobalKey(); - - BuildContext context; - - // What did we search for last time? - String _cachedQuery = ""; - - bool _searching = false; - - // Custom filters for the part search - Map _filters = {}; - - PartSearchDelegate(this.context, {Map filters = const {}}) { - - // Copy filter values - for (String key in filters.keys) { - - String? value = filters[key]; - - if (value != null) { - _filters[key] = value; - } - } - } +// Widget for performing database-wide search +class SearchWidget extends StatefulWidget { @override - String get searchFieldLabel => L10().searchParts; + _SearchDisplayState createState() => _SearchDisplayState(); - // List of part results - List partResults = []; - - Future search(BuildContext context) async { - - // Search string too short! - if (query.length < 3) { - partResults.clear(); - showResults(context); - return; - } - - if (query == _cachedQuery) { - return; - } - - _cachedQuery = query; - - _searching = true; - - print("Searching..."); - - showResults(context); - - _filters["cascade"] = "true"; - - final results = await InvenTreePart().search(context, query, filters: _filters); - - partResults.clear(); - - for (int idx = 0; idx < results.length; idx++) { - if (results[idx] is InvenTreePart) { - partResults.add(results[idx] as InvenTreePart); - } - } - - print("Searching complete! Results: ${partResults.length}"); - _searching = false; - - showSnackIcon( - "${partResults.length} ${L10().results}", - success: partResults.length > 0, - icon: FontAwesomeIcons.pollH, - ); - - // For some reason, need to toggle between suggestions and results here... - showSuggestions(context); - showResults(context); - } - - @override - List buildActions(BuildContext context) { - return [ - IconButton( - icon: FaIcon(FontAwesomeIcons.backspace), - onPressed: () { - query = ''; - search(context); - }, - ), - IconButton( - icon: FaIcon(FontAwesomeIcons.search), - onPressed: () { - search(context); - } - ), - ]; - } - - @override - Widget buildLeading(BuildContext context) { - return IconButton( - icon: Icon(Icons.arrow_back), - onPressed: () { - this.close(context, null); - } - ); - } - - Widget _partResult(BuildContext context, int index) { - - InvenTreePart part = partResults[index]; - - return ListTile( - title: Text(part.fullname), - subtitle: Text(part.description), - leading: InvenTreeAPI().getImage( - part.thumbnail, - width: 40, - height: 40 - ), - trailing: Text(part.inStockString), - onTap: () { - InvenTreePart().get(part.pk).then((var prt) { - if (prt is InvenTreePart) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => PartDetailWidget(prt)) - ); - } - }); - } - ); - } - - @override - Widget buildResults(BuildContext context) { - - print("build results"); - - if (_searching) { - return progressIndicator(); - } - - search(context); - - if (query.length == 0) { - return ListTile( - title: Text(L10().queryEnter) - ); - } - - if (query.length < 3) { - return ListTile( - title: Text(L10().queryShort), - subtitle: Text(L10().queryShortDetail) - ); - } - - if (partResults.length == 0) { - return ListTile( - title: Text(L10().noResults), - subtitle: Text(L10().queryNoResults + " '${query}'") - ); - } - - return ListView.separated( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - separatorBuilder: (_, __) => const Divider(height: 3), - itemBuilder: _partResult, - itemCount: partResults.length, - ); - } - - @override - Widget buildSuggestions(BuildContext context) { - // TODO - Implement - return Column(); - } - - // Ensure the search theme matches the app theme - @override - ThemeData appBarTheme(BuildContext context) { - final ThemeData theme = Theme.of(context); - return theme; - } } - -class StockSearchDelegate extends SearchDelegate { - - final stockSearchKey = GlobalKey(); - - final BuildContext context; - - String _cachedQuery = ""; - - bool _searching = false; - - // Custom filters for the stock item search - Map _filters = {}; - - StockSearchDelegate(this.context, {Map filters = const {}}) { - - // Copy filter values - for (String key in filters.keys) { - - String? value = filters[key]; - - if (value != null) { - _filters[key] = value; - } - } - } +class _SearchDisplayState extends RefreshableState { @override - String get searchFieldLabel => L10().searchStock; + String getAppBarTitle(BuildContext context) => L10().search; - // List of StockItem results - List itemResults = []; + final TextEditingController searchController = TextEditingController(); + + Timer? debounceTimer; + + int nPartResults = 0; + + int nCategoryResults = 0; + + int nStockResults = 0; + + int nLocationResults = 0; + + int nSupplierResults = 0; + + int nPurchaseOrderResults = 0; + + // Callback when the text is being edited + // Incorporates a debounce timer to restrict search frequency + void onSearchTextChanged(String text, {bool immediate = false}) { + + if (debounceTimer?.isActive ?? false) { + debounceTimer!.cancel(); + } + + if (immediate) { + search(text); + } else { + debounceTimer = Timer(Duration(milliseconds: 250), () { + search(text); + }); + } + + } + + Future search(String term) async { + + if (term.isEmpty) { + setState(() { + // Do not search on an empty string + nPartResults = 0; + nCategoryResults = 0; + nStockResults = 0; + nLocationResults = 0; + nSupplierResults = 0; + nPurchaseOrderResults = 0; + }); - Future search(BuildContext context) async { - // Search string too short! - if (query.length < 3) { - itemResults.clear(); - showResults(context); return; } - if (query == _cachedQuery) { - return; - } + // Search parts + InvenTreePart().count( + searchQuery: term + ).then((int n) { + setState(() { + nPartResults = n; + }); + }); - _cachedQuery = query; + // Search part categories + InvenTreePartCategory().count( + searchQuery: term, + ).then((int n) { + setState(() { + nCategoryResults = n; + }); + }); - _searching = true; + // Search stock items + InvenTreeStockItem().count( + searchQuery: term + ).then((int n) { + setState(() { + nStockResults = n; + }); + }); - print("Searching..."); + // Search stock locations + InvenTreeStockLocation().count( + searchQuery: term + ).then((int n) { + setState(() { + nLocationResults = n; + }); + }); - showResults(context); + // Search suppliers + InvenTreeCompany().count( + searchQuery: term, + filters: { + "is_supplier": "true", + }, + ).then((int n) { + setState(() { + nSupplierResults = n; + }); + }); - // Enable cascading part search by default - _filters["cascade"] = "true"; - - final results = await InvenTreeStockItem().search( - context, query, filters: _filters); - - itemResults.clear(); - - for (int idx = 0; idx < results.length; idx++) { - if (results[idx] is InvenTreeStockItem) { - itemResults.add(results[idx] as InvenTreeStockItem); + // Search purchase orders + InvenTreePurchaseOrder().count( + searchQuery: term, + filters: { + "outstanding": "true" } - } + ).then((int n) { + setState(() { + nPurchaseOrderResults = n; + }); + }); - _searching = false; + } - showSnackIcon( - "${itemResults.length} ${L10().results}", - success: itemResults.length > 0, - icon: FontAwesomeIcons.pollH, + List _tiles(BuildContext context) { + + List tiles = []; + + // Search input + tiles.add( + InputDecorator( + decoration: InputDecoration( + ), + child: ListTile( + title: TextField( + readOnly: false, + controller: searchController, + onChanged: (String text) { + onSearchTextChanged(text); + }, + ), + leading: IconButton( + icon: FaIcon(FontAwesomeIcons.backspace, color: Colors.red), + onPressed: () { + searchController.clear(); + onSearchTextChanged("", immediate: true); + }, + ), + ) + ) ); - showSuggestions(context); - showResults(context); - } + String query = searchController.text; - @override - List buildActions(BuildContext context) { - return [ - IconButton( - icon: FaIcon(FontAwesomeIcons.backspace), - onPressed: () { - query = ''; - search(context); - }, - ), - IconButton( - icon: FaIcon(FontAwesomeIcons.search), - onPressed: () { - search(context); - } - ), - ]; - } + List results = []; - @override - Widget buildLeading(BuildContext context) { - return IconButton( - icon: Icon(Icons.arrow_back), - onPressed: () { - this.close(context, null); - } - ); - } - - Widget _itemResult(BuildContext context, int index) { - - InvenTreeStockItem item = itemResults[index]; - - return ListTile( - title: Text(item.partName), - subtitle: Text(item.locationName), - leading: InvenTreeAPI().getImage( - item.partThumbnail, - width: 40, - height: 40, - ), - trailing: Text(item.serialOrQuantityDisplay()), - onTap: () { - InvenTreeStockItem().get(item.pk).then((var it) { - if (it is InvenTreeStockItem) { + // Part Results + if (nPartResults > 0) { + results.add( + ListTile( + title: Text(L10().parts), + leading: FaIcon(FontAwesomeIcons.shapes), + trailing: Text("${nPartResults}"), + onTap: () { Navigator.push( - context, - MaterialPageRoute(builder: (context) => StockDetailWidget(it)) + context, + MaterialPageRoute( + builder: (context) => PartList( + { + "original_search": query + } + ) + ) ); } - }); + ) + ); + } + + // Part Category Results + if (nCategoryResults > 0) { + results.add( + ListTile( + title: Text(L10().partCategories), + leading: FaIcon(FontAwesomeIcons.sitemap), + trailing: Text("${nCategoryResults}"), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PartCategoryList( + { + "original_search": query + } + ) + ) + ); + }, + ) + ); + } + + // Stock Item Results + if (nStockResults > 0) { + results.add( + ListTile( + title: Text(L10().stockItems), + leading: FaIcon(FontAwesomeIcons.boxes), + trailing: Text("${nStockResults}"), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => StockItemList( + { + "original_search": query, + } + ) + ) + ); + }, + ) + ); + } + + // Stock location results + if (nLocationResults > 0) { + results.add( + ListTile( + title: Text(L10().stockLocations), + leading: FaIcon(FontAwesomeIcons.mapMarkerAlt), + trailing: Text("${nLocationResults}"), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => StockLocationList( + { + "original_search": query + } + ) + ) + ); + }, + ) + ); + } + + // Suppliers + if (nSupplierResults > 0) { + results.add( + ListTile( + title: Text(L10().suppliers), + leading: FaIcon(FontAwesomeIcons.building), + trailing: Text("${nSupplierResults}"), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CompanyListWidget( + L10().suppliers, + { + "is_supplier": "true", + "original_search": query + } + ) + ) + ); + }, + ) + ); + } + + // Purchase orders + if (nPurchaseOrderResults > 0) { + results.add( + ListTile( + title: Text(L10().purchaseOrders), + leading: FaIcon(FontAwesomeIcons.shoppingCart), + trailing: Text("${nPurchaseOrderResults}"), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PurchaseOrderListWidget( + filters: { + "original_search": query + } + ) + ) + ); + }, + ) + ); + } + + if (results.isEmpty) { + tiles.add( + ListTile( + title: Text(L10().queryNoResults), + leading: FaIcon(FontAwesomeIcons.search), + ) + ); + } else { + for (Widget result in results) { + tiles.add(result); } + } + + return tiles; + + } + + @override + Widget getBody(BuildContext context) { + return Center( + child: ListView( + children: ListTile.divideTiles( + context: context, + tiles: _tiles(context), + ).toList() + ) ); } - - @override - Widget buildResults(BuildContext context) { - - search(context); - - if (_searching) { - return progressIndicator(); - } - - search(context); - - if (query.length == 0) { - return ListTile( - title: Text(L10().queryEnter) - ); - } - - if (query.length < 3) { - return ListTile( - title: Text(L10().queryShort), - subtitle: Text(L10().queryShortDetail) - ); - } - - if (itemResults.length == 0) { - return ListTile( - title: Text(L10().noResults), - subtitle: Text(L10().queryNoResults + " '${query}'") - ); - } - - return ListView.separated( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - separatorBuilder: (_, __) => const Divider(height: 3), - itemBuilder: _itemResult, - itemCount: itemResults.length, - ); - } - - @override - Widget buildSuggestions(BuildContext context) { - // TODO - Implement - return Column(); - } - - // Ensure the search theme matches the app theme - @override - ThemeData appBarTheme(BuildContext context) { - final ThemeData theme = Theme.of(context); - return theme; - } -} \ No newline at end of file +} diff --git a/lib/widget/snacks.dart b/lib/widget/snacks.dart index 7d3e1578..fdbeb1c0 100644 --- a/lib/widget/snacks.dart +++ b/lib/widget/snacks.dart @@ -8,16 +8,20 @@ * | Text | */ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:one_context/one_context.dart'; -import 'package:inventree/l10.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:one_context/one_context.dart"; +import "package:inventree/l10.dart"; void showSnackIcon(String text, {IconData? icon, Function()? onAction, bool? success, String? actionText}) { - OneContext().hideCurrentSnackBar(); + BuildContext? context = OneContext().context; + + if (context != null) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + } Color backgroundColor = Colors.deepOrange; diff --git a/lib/widget/spinner.dart b/lib/widget/spinner.dart index 1770a90f..faebe750 100644 --- a/lib/widget/spinner.dart +++ b/lib/widget/spinner.dart @@ -1,13 +1,10 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/cupertino.dart'; +import "package:flutter/material.dart"; +import "package:flutter/cupertino.dart"; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/app_colors.dart'; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/app_colors.dart"; class Spinner extends StatefulWidget { - final IconData? icon; - final Duration duration; - final Color color; const Spinner({ this.color = COLOR_GRAY_LIGHT, @@ -16,12 +13,16 @@ class Spinner extends StatefulWidget { this.duration = const Duration(milliseconds: 1800), }) : super(key: key); + final IconData? icon; + final Duration duration; + final Color color; + @override _SpinnerState createState() => _SpinnerState(); } class _SpinnerState extends State with SingleTickerProviderStateMixin { - AnimationController? _controller; + late AnimationController? _controller; Widget? _child; @override diff --git a/lib/widget/starred_parts.dart b/lib/widget/starred_parts.dart index df439f79..fbb33936 100644 --- a/lib/widget/starred_parts.dart +++ b/lib/widget/starred_parts.dart @@ -1,20 +1,18 @@ +import "package:inventree/inventree/part.dart"; +import "package:inventree/widget/part_detail.dart"; +import "package:inventree/widget/progress.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:inventree/l10.dart"; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/widget/part_detail.dart'; -import 'package:inventree/widget/progress.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; - -import 'package:inventree/l10.dart'; - -import '../api.dart'; +import "package:inventree/api.dart"; class StarredPartWidget extends StatefulWidget { - StarredPartWidget({Key? key}) : super(key: key); + const StarredPartWidget({Key? key}) : super(key: key); @override _StarredPartState createState() => _StarredPartState(); @@ -73,7 +71,7 @@ class _StarredPartState extends RefreshableState { return progressIndicator(); } - if (starredParts.length == 0) { + if (starredParts.isEmpty) { return ListView( children: [ ListTile( diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index 28ac29cb..46e49cfb 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -1,30 +1,30 @@ -import 'package:inventree/app_colors.dart'; -import 'package:inventree/barcode.dart'; -import 'package:inventree/inventree/model.dart'; -import 'package:inventree/inventree/stock.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/widget/dialogs.dart'; -import 'package:inventree/widget/fields.dart'; -import 'package:inventree/widget/location_display.dart'; -import 'package:inventree/widget/part_detail.dart'; -import 'package:inventree/widget/progress.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:inventree/widget/snacks.dart'; -import 'package:inventree/widget/stock_item_test_results.dart'; -import 'package:inventree/widget/stock_notes.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; +import "package:inventree/app_colors.dart"; +import "package:inventree/barcode.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/widget/dialogs.dart"; +import "package:inventree/widget/fields.dart"; +import "package:inventree/widget/location_display.dart"; +import "package:inventree/widget/part_detail.dart"; +import "package:inventree/widget/progress.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/widget/snacks.dart"; +import "package:inventree/widget/stock_item_test_results.dart"; +import "package:inventree/widget/stock_notes.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; -import 'package:inventree/l10.dart'; +import "package:inventree/l10.dart"; +import "package:inventree/helpers.dart"; +import "package:inventree/api.dart"; -import 'package:inventree/api.dart'; - -import 'package:dropdown_search/dropdown_search.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import "package:dropdown_search/dropdown_search.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; class StockDetailWidget extends StatefulWidget { - StockDetailWidget(this.item, {Key? key}) : super(key: key); + const StockDetailWidget(this.item, {Key? key}) : super(key: key); final InvenTreeStockItem item; @@ -35,6 +35,8 @@ class StockDetailWidget extends StatefulWidget { class _StockItemDisplayState extends RefreshableState { + _StockItemDisplayState(this.item); + @override String getAppBarTitle(BuildContext context) => L10().stockItem; @@ -46,14 +48,12 @@ class _StockItemDisplayState extends RefreshableState { final _countStockKey = GlobalKey(); final _moveStockKey = GlobalKey(); - _StockItemDisplayState(this.item); - @override List getAppBarActions(BuildContext context) { List actions = []; - if (InvenTreeAPI().checkPermission('stock', 'view')) { + if (InvenTreeAPI().checkPermission("stock", "view")) { actions.add( IconButton( icon: FaIcon(FontAwesomeIcons.globe), @@ -62,7 +62,7 @@ class _StockItemDisplayState extends RefreshableState { ); } - if (InvenTreeAPI().checkPermission('stock', 'change')) { + if (InvenTreeAPI().checkPermission("stock", "change")) { actions.add( IconButton( icon: FaIcon(FontAwesomeIcons.edit), @@ -99,13 +99,13 @@ class _StockItemDisplayState extends RefreshableState { await item.reload(); // Request part information - part = await InvenTreePart().get(item.partId) as InvenTreePart; + part = await InvenTreePart().get(item.partId) as InvenTreePart?; // Request test results... await item.getTestResults(); } - void _editStockItem(BuildContext context) async { + Future _editStockItem(BuildContext context) async { var fields = InvenTreeStockItem().formFields(); @@ -125,7 +125,7 @@ class _StockItemDisplayState extends RefreshableState { } - void _addStock() async { + Future _addStock() async { double quantity = double.parse(_quantityController.text); _quantityController.clear(); @@ -138,7 +138,7 @@ class _StockItemDisplayState extends RefreshableState { refresh(); } - void _addStockDialog() async { + Future _addStockDialog() async { _quantityController.clear(); _notesController.clear(); @@ -171,7 +171,7 @@ class _StockItemDisplayState extends RefreshableState { } } - void _removeStock() async { + Future _removeStock() async { double quantity = double.parse(_quantityController.text); _quantityController.clear(); @@ -211,7 +211,7 @@ class _StockItemDisplayState extends RefreshableState { ); } - void _countStock() async { + Future _countStock() async { double quantity = double.parse(_quantityController.text); _quantityController.clear(); @@ -223,9 +223,9 @@ class _StockItemDisplayState extends RefreshableState { refresh(); } - void _countStockDialog() async { + Future _countStockDialog() async { - _quantityController.text = item.quantityString; + _quantityController.text = item.quantity.toString(); _notesController.clear(); showFormDialog(L10().countStock, @@ -251,9 +251,9 @@ class _StockItemDisplayState extends RefreshableState { } - void _unassignBarcode(BuildContext context) async { + Future _unassignBarcode(BuildContext context) async { - final bool result = await item.update(values: {'uid': ''}); + final bool result = await item.update(values: {"uid": ""}); if (result) { showSnackIcon( @@ -271,7 +271,7 @@ class _StockItemDisplayState extends RefreshableState { } - void _transferStock(int locationId) async { + Future _transferStock(int locationId) async { double quantity = double.tryParse(_quantityController.text) ?? item.quantity; String notes = _notesController.text; @@ -288,11 +288,11 @@ class _StockItemDisplayState extends RefreshableState { } } - void _transferStockDialog(BuildContext context) async { + Future _transferStockDialog(BuildContext context) async { int? location_pk; - _quantityController.text = "${item.quantityString}"; + _quantityController.text = "${item.quantity}"; showFormDialog(L10().transferStock, key: _moveStockKey, @@ -327,13 +327,7 @@ class _StockItemDisplayState extends RefreshableState { }, onFind: (String filter) async { - Map _filters = { - "search": filter, - "offset": "0", - "limit": "25" - }; - - final List results = await InvenTreeStockLocation().list(filters: _filters); + final results = await InvenTreeStockLocation().search(filter); List items = []; @@ -349,13 +343,13 @@ class _StockItemDisplayState extends RefreshableState { hint: L10().searchLocation, onChanged: null, itemAsString: (dynamic location) { - return location['pathstring']; + return (location["pathstring"] ?? "") as String; }, onSaved: (dynamic location) { if (location == null) { location_pk = null; } else { - location_pk = location['pk']; + location_pk = location["pk"] as int; } }, isFilteredOnline: true, @@ -420,7 +414,7 @@ class _StockItemDisplayState extends RefreshableState { ListTile( title: Text(L10().quantity), leading: FaIcon(FontAwesomeIcons.cubes), - trailing: Text("${item.quantityString}"), + trailing: Text("${item.quantityString()}"), ) ); } @@ -503,7 +497,8 @@ class _StockItemDisplayState extends RefreshableState { // Supplier part? // TODO: Display supplier part info page? - if (false && item.supplierPartId > 0) { + /* + if (item.supplierPartId > 0) { tiles.add( ListTile( title: Text("${item.supplierName}"), @@ -514,6 +509,7 @@ class _StockItemDisplayState extends RefreshableState { ) ); } + */ if (item.link.isNotEmpty) { tiles.add( @@ -559,7 +555,8 @@ class _StockItemDisplayState extends RefreshableState { // TODO - Is this stock item linked to a PurchaseOrder? // TODO - Re-enable stock item history display - if (false && item.trackingItemCount > 0) { + /* + if (item.trackingItemCount > 0) { tiles.add( ListTile( title: Text(L10().history), @@ -574,6 +571,7 @@ class _StockItemDisplayState extends RefreshableState { ) ); } + */ // Notes field tiles.add( @@ -600,7 +598,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add(headerTile()); // First check that the user has the required permissions to adjust stock - if (!InvenTreeAPI().checkPermission('stock', 'change')) { + if (!InvenTreeAPI().checkPermission("stock", "change")) { tiles.add( ListTile( title: Text(L10().permissionRequired), @@ -624,7 +622,7 @@ class _StockItemDisplayState extends RefreshableState { title: Text(L10().countStock), leading: FaIcon(FontAwesomeIcons.checkCircle, color: COLOR_CLICK), onTap: _countStockDialog, - trailing: Text(item.quantityString), + trailing: Text(item.quantityString(includeUnits: true)), ) ); @@ -678,12 +676,31 @@ class _StockItemDisplayState extends RefreshableState { leading: FaIcon(FontAwesomeIcons.barcode, color: COLOR_CLICK), trailing: FaIcon(FontAwesomeIcons.qrcode), onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => InvenTreeQRView(StockItemBarcodeAssignmentHandler(item))) - ).then((context) { - refresh(); + + var handler = UniqueBarcodeHandler((String hash) { + item.update( + values: { + "uid": hash, + } + ).then((result) { + if (result) { + successTone(); + + showSnackIcon( + L10().barcodeAssigned, + success: true, + icon: FontAwesomeIcons.qrcode + ); + + refresh(); + } + }); }); + + Navigator.push( + context, + MaterialPageRoute(builder: (context) => InvenTreeQRView(handler)) + ); } ) ); @@ -710,12 +727,11 @@ class _StockItemDisplayState extends RefreshableState { items: [ BottomNavigationBarItem( icon: FaIcon(FontAwesomeIcons.infoCircle), - title: Text(L10().details), + label: L10().details, ), BottomNavigationBarItem( icon: FaIcon(FontAwesomeIcons.wrench), - title: Text(L10().actions), - ), + label: L10().actions, ), ] ); } diff --git a/lib/widget/stock_item_test_results.dart b/lib/widget/stock_item_test_results.dart index 5c8ce974..d7d3a7cf 100644 --- a/lib/widget/stock_item_test_results.dart +++ b/lib/widget/stock_item_test_results.dart @@ -1,27 +1,21 @@ -import 'package:inventree/api_form.dart'; -import 'package:inventree/app_colors.dart'; -import 'package:inventree/inventree/part.dart'; -import 'package:inventree/inventree/stock.dart'; -import 'package:inventree/inventree/model.dart'; -import 'package:inventree/api.dart'; -import 'package:inventree/widget/dialogs.dart'; -import 'package:inventree/widget/fields.dart'; -import 'package:inventree/widget/progress.dart'; -import 'package:inventree/widget/snacks.dart'; +import "package:inventree/app_colors.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/api.dart"; +import "package:inventree/widget/progress.dart"; -import 'package:inventree/l10.dart'; +import "package:inventree/l10.dart"; -import 'dart:io'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; class StockItemTestResultsWidget extends StatefulWidget { - StockItemTestResultsWidget(this.item, {Key? key}) : super(key: key); + const StockItemTestResultsWidget(this.item, {Key? key}) : super(key: key); final InvenTreeStockItem item; @@ -32,7 +26,7 @@ class StockItemTestResultsWidget extends StatefulWidget { class _StockItemTestResultDisplayState extends RefreshableState { - final _addResultKey = GlobalKey(); + _StockItemTestResultDisplayState(this.item); @override String getAppBarTitle(BuildContext context) => L10().testResults; @@ -57,9 +51,7 @@ class _StockItemTestResultDisplayState extends RefreshableState addTestResult(BuildContext context, {String name = "", bool nameIsEditable = true, bool result = false, String value = "", bool valueRequired = false, bool attachmentRequired = false}) async { InvenTreeStockItemTestResult().createForm( context, @@ -150,7 +142,7 @@ class _StockItemTestResultDisplayState extends RefreshableState filters; + + @override + _StockListState createState() => _StockListState(filters); +} + + +class _StockListState extends RefreshableState { + + _StockListState(this.filters); + + final Map filters; + + @override + String getAppBarTitle(BuildContext context) => L10().purchaseOrders; + + @override + Widget getBody(BuildContext context) { + return PaginatedStockItemList(filters); + } +} + +class PaginatedStockItemList extends StatefulWidget { + + const PaginatedStockItemList(this.filters); + + final Map filters; + + @override + _PaginatedStockItemListState createState() => _PaginatedStockItemListState(filters); + +} + + +class _PaginatedStockItemListState extends PaginatedSearchState { + + _PaginatedStockItemListState(Map filters) : super(filters); + + @override + Future requestPage(int limit, int offset, Map params) async { + + // Do we include stock items from sub-locations? + final bool cascade = await InvenTreeSettingsManager().getBool("stockSublocation", true); + + params["cascade"] = "${cascade}"; + + final page = await InvenTreeStockItem().listPaginated( + limit, + offset, + filters: params + ); + + return page; + } + + void _openItem(BuildContext context, int pk) { + InvenTreeStockItem().get(pk).then((var item) { + if (item is InvenTreeStockItem) { + Navigator.push(context, MaterialPageRoute(builder: (context) => StockDetailWidget(item))); + } + }); + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + + InvenTreeStockItem item = model as InvenTreeStockItem; + + return ListTile( + title: Text("${item.partName}"), + subtitle: Text("${item.locationPathString}"), + leading: InvenTreeAPI().getImage( + item.partThumbnail, + width: 40, + height: 40, + ), + trailing: Text("${item.displayQuantity}", + style: TextStyle( + fontWeight: FontWeight.bold, + color: item.statusColor, + ), + ), + onTap: () { + _openItem(context, item.pk); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widget/stock_notes.dart b/lib/widget/stock_notes.dart index 130f863c..28c1c890 100644 --- a/lib/widget/stock_notes.dart +++ b/lib/widget/stock_notes.dart @@ -1,20 +1,20 @@ -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/inventree/stock.dart'; -import 'package:inventree/widget/refreshable_state.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:inventree/l10.dart'; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter_markdown/flutter_markdown.dart"; +import "package:inventree/l10.dart"; -import '../api.dart'; +import "package:inventree/api.dart"; class StockNotesWidget extends StatefulWidget { - final InvenTreeStockItem item; + const StockNotesWidget(this.item, {Key? key}) : super(key: key); - StockNotesWidget(this.item, {Key? key}) : super(key: key); + final InvenTreeStockItem item; @override _StockNotesState createState() => _StockNotesState(item); @@ -23,10 +23,10 @@ class StockNotesWidget extends StatefulWidget { class _StockNotesState extends RefreshableState { - final InvenTreeStockItem item; - _StockNotesState(this.item); + final InvenTreeStockItem item; + @override String getAppBarTitle(BuildContext context) => L10().stockItemNotes; @@ -39,7 +39,7 @@ class _StockNotesState extends RefreshableState { List getAppBarActions(BuildContext context) { List actions = []; - if (InvenTreeAPI().checkPermission('stock', 'change')) { + if (InvenTreeAPI().checkPermission("stock", "change")) { actions.add( IconButton( icon: FaIcon(FontAwesomeIcons.edit), diff --git a/lib/widget/submit_feedback.dart b/lib/widget/submit_feedback.dart index b335d6f0..201929c3 100644 --- a/lib/widget/submit_feedback.dart +++ b/lib/widget/submit_feedback.dart @@ -1,12 +1,10 @@ +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/inventree/sentry.dart"; +import "package:inventree/widget/snacks.dart"; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:inventree/inventree/sentry.dart'; -import 'package:inventree/widget/snacks.dart'; - -import '../l10.dart'; +import "package:inventree/l10.dart"; class SubmitFeedbackWidget extends StatefulWidget { @@ -18,7 +16,7 @@ class SubmitFeedbackWidget extends StatefulWidget { class _SubmitFeedbackState extends State { - final _formkey = new GlobalKey(); + final _formkey = GlobalKey(); String message = ""; @@ -61,8 +59,6 @@ class _SubmitFeedbackState extends State { key: _formkey, child: SingleChildScrollView( child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFormField( diff --git a/pubspec.lock b/pubspec.lock index 8c201dda..6a199be7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,7 +49,21 @@ packages: name: cached_network_image url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.1.0" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" camera: dependency: "direct main" description: @@ -113,6 +127,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.3" + datetime_picker_formfield: + dependency: "direct main" + description: + name: datetime_picker_formfield + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" device_info_plus: dependency: "direct main" description: @@ -315,6 +336,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.3" + lint: + dependency: "direct dev" + description: + name: lint + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" markdown: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6e5e8c00..a6b94af1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,40 +13,43 @@ environment: sdk: ">=2.12.0 <3.0.0" dependencies: + + audioplayers: ^0.20.1 # Play audio files + cached_network_image: ^3.1.0 # Download and cache remote images + camera: # Camera + cupertino_icons: ^1.0.3 + datetime_picker_formfield: ^2.0.0 # Date / time picker + device_info_plus: ^2.1.0 # Information about the device + dropdown_search: 0.6.3 # Dropdown autocomplete form fields + file_picker: ^4.0.0 # Select files from the device + flutter: sdk: flutter flutter_localizations: sdk: flutter - intl: ^0.17.0 - - cupertino_icons: ^1.0.3 - http: ^0.13.0 - cached_network_image: ^3.0.0 # Download and cache remote images - qr_code_scanner: ^0.5.2 # Barcode scanning - package_info_plus: ^1.0.4 # App information introspection - device_info_plus: ^2.1.0 # Information about the device - font_awesome_flutter: ^9.1.0 # FontAwesome icon set - sentry_flutter: 5.0.0 # Error reporting - image_picker: ^0.8.3 # Select or take photos - file_picker: ^4.0.0 # Select files from the device - url_launcher: 6.0.9 # Open link in system browser - open_file: 3.2.1 # Open local files flutter_markdown: ^0.6.2 # Rendering markdown - camera: # Camera - path_provider: 2.0.2 # Local file storage - sembast: ^3.1.0+2 # NoSQL data storage - one_context: ^1.1.0 # Dialogs without requiring context + font_awesome_flutter: ^9.1.0 # FontAwesome icon set + http: ^0.13.0 + image_picker: ^0.8.3 # Select or take photos infinite_scroll_pagination: ^3.1.0 # Let the server do all the work! - audioplayers: ^0.20.1 # Play audio files - dropdown_search: 0.6.3 # Dropdown autocomplete form fields + intl: ^0.17.0 + one_context: ^1.1.0 # Dialogs without requiring context + open_file: 3.2.1 # Open local files + package_info_plus: ^1.0.4 # App information introspection path: + path_provider: 2.0.2 # Local file storage + qr_code_scanner: ^0.5.2 # Barcode scanning + sembast: ^3.1.0+2 # NoSQL data storage + sentry_flutter: 5.0.0 # Error reporting + url_launcher: 6.0.9 # Open link in system browser dev_dependencies: + flutter_launcher_icons: flutter_test: sdk: flutter - flutter_launcher_icons: + lint: ^1.0.0 flutter_icons: android: true diff --git a/test/widget_test.dart b/test/widget_test.dart index 661b665f..3c73102a 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,9 +5,9 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter_test/flutter_test.dart'; +import "package:flutter_test/flutter_test.dart"; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { + testWidgets("Counter increments smoke test", (WidgetTester tester) async { }); }