2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-05-01 23:16:49 +00:00

Merge branch 'profile-support'

This commit is contained in:
Oliver Walters 2021-02-09 21:39:22 +11:00
commit 3c560c395c
24 changed files with 1279 additions and 555 deletions

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:InvenTree/user_profile.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@ -67,15 +68,6 @@ class InvenTreeAPI {
static const _URL_GET_TOKEN = "user/token/"; static const _URL_GET_TOKEN = "user/token/";
static const _URL_GET_VERSION = ""; static const _URL_GET_VERSION = "";
Future<void> showServerError(BuildContext context, String description) async {
showErrorDialog(
context,
I18N.of(context).serverError,
description,
icon: FontAwesomeIcons.server
);
}
// Base URL for InvenTree API e.g. http://192.168.120.10:8000 // Base URL for InvenTree API e.g. http://192.168.120.10:8000
String _BASE_URL = ""; String _BASE_URL = "";
@ -108,14 +100,11 @@ class InvenTreeAPI {
String makeUrl(String endpoint) => _makeUrl(endpoint); String makeUrl(String endpoint) => _makeUrl(endpoint);
String _username = ""; UserProfile profile;
String _password = "";
// Authentication token (initially empty, must be requested) // Authentication token (initially empty, must be requested)
String _token = ""; String _token = "";
bool isConnected() => _token.isNotEmpty;
/* /*
* Check server connection and display messages if not connected. * Check server connection and display messages if not connected.
* Useful as a precursor check before performing operations. * Useful as a precursor check before performing operations.
@ -126,10 +115,10 @@ class InvenTreeAPI {
showDialog( showDialog(
context: context, context: context,
child: new SimpleDialog( child: new SimpleDialog(
title: new Text("Not Connected"), title: new Text(I18N.of(context).notConnected),
children: <Widget>[ children: <Widget>[
ListTile( ListTile(
title: Text("Server not connected"), title: Text(I18N.of(context).serverNotConnected),
) )
] ]
) )
@ -154,8 +143,14 @@ class InvenTreeAPI {
// Connection status flag - set once connection has been validated // Connection status flag - set once connection has been validated
bool _connected = false; bool _connected = false;
bool get connected { bool _connecting = true;
return _connected && baseUrl.isNotEmpty && _token.isNotEmpty;
bool isConnected() {
return profile != null && _connected && baseUrl.isNotEmpty && _token.isNotEmpty;
}
bool isConnecting() {
return !isConnected() && _connecting;
} }
// Ensure we only ever create a single instance of the API class // Ensure we only ever create a single instance of the API class
@ -167,26 +162,17 @@ class InvenTreeAPI {
InvenTreeAPI._internal(); InvenTreeAPI._internal();
Future<bool> connect(BuildContext context) async { Future<bool> _connect(BuildContext context) async {
var prefs = await SharedPreferences.getInstance();
String server = prefs.getString("server");
String username = prefs.getString("username");
String password = prefs.getString("password");
return connectToServer(context, server, username, password);
}
Future<bool> connectToServer(BuildContext context, String address, String username, String password) async {
/* Address is the base address for the InvenTree server, /* Address is the base address for the InvenTree server,
* e.g. http://127.0.0.1:8000 * e.g. http://127.0.0.1:8000
*/ */
String errorMessage = ""; if (profile == null) return false;
address = address.trim(); String address = profile.server.trim();
username = username.trim(); String username = profile.username.trim();
String password = profile.password.trim();
if (address.isEmpty || username.isEmpty || password.isEmpty) { if (address.isEmpty || username.isEmpty || password.isEmpty) {
await showErrorDialog( await showErrorDialog(
@ -211,41 +197,39 @@ class InvenTreeAPI {
*/ */
_BASE_URL = address; _BASE_URL = address;
_username = username;
_password = password;
_connected = false; print("Connecting to ${apiUrl} -> ${username}:${password}");
print("Connecting to " + apiUrl + " -> " + username + ":" + password);
var response = await get("").timeout(Duration(seconds: 10)).catchError((error) { var response = await get("").timeout(Duration(seconds: 10)).catchError((error) {
print("Error connecting to server: ${error.toString()}");
if (error is SocketException) { if (error is SocketException) {
errorMessage = "Could not connect to server"; showServerError(
context,
I18N.of(context).connectionRefused,
error.toString());
return null; return null;
} else if (error is TimeoutException) { } else if (error is TimeoutException) {
errorMessage = "Server timeout"; showTimeoutError(context);
return null; return null;
} else { } else {
// Unknown error type // Unknown error type - re-throw the error and Sentry will catch it
errorMessage = error.toString(); throw error;
// Unknown error type, re-throw error
return null;
} }
}); });
if (response == null) { if (response == null) {
// Null (or error) response: Show dialog and exit // Null (or error) response: Show dialog and exit
await showServerError(context, errorMessage);
return false; return false;
} }
if (response.statusCode != 200) { if (response.statusCode != 200) {
// Any status code other than 200! // Any status code other than 200!
showStatusCodeError(context, response.statusCode);
// TODO: Interpret the error codes and show custom message? // TODO: Interpret the error codes and show custom message?
await showServerError(context, "Invalid response code: ${response.statusCode.toString()}");
return false; return false;
} }
@ -254,29 +238,30 @@ class InvenTreeAPI {
print("Response from server: $data"); print("Response from server: $data");
// We expect certain response from the server // We expect certain response from the server
if (!data.containsKey("server") || !data.containsKey("version")) { if (!data.containsKey("server") || !data.containsKey("version") || !data.containsKey("instance")) {
showServerError(
context,
"Missing Data",
"Server response missing required fields"
);
await showServerError(context, "Server response missing required fields");
return false; return false;
} }
print("Server: " + data["server"]); // Record server information
print("Version: " + data["version"]);
_version = data["version"]; _version = data["version"];
if (!_checkServerVersion(_version)) {
await showServerError(context, "Server version is too old.\n\nServer Version: ${_version}\n\nRequired version: ${_requiredVersionString}");
return false;
}
// Record the instance name of the server
instance = data['instance'] ?? ''; instance = data['instance'] ?? '';
// Request token from the server if we do not already have one // Check that the remote server version is *new* enough
if (false && _token.isNotEmpty) { if (!_checkServerVersion(_version)) {
print("Already have token - $_token"); showServerError(
return true; context,
"Old Server Version",
"\n\nServer Version: ${_version}\n\nRequired version: ${_requiredVersionString}"
);
return false;
} }
// Clear the existing token value // Clear the existing token value
@ -285,24 +270,33 @@ class InvenTreeAPI {
print("Requesting token from server"); print("Requesting token from server");
response = await get(_URL_GET_TOKEN).timeout(Duration(seconds: 10)).catchError((error) { response = await get(_URL_GET_TOKEN).timeout(Duration(seconds: 10)).catchError((error) {
print("Error requesting token:"); print("Error requesting token:");
print(error); print(error);
return null;
}); });
if (response == null) { if (response == null) {
await showServerError(context, "Error requesting access token"); showServerError(
context, "Token Error", "Error requesting access token from server"
);
return false; return false;
} }
if (response.statusCode != 200) { if (response.statusCode != 200) {
await showServerError(context, "Invalid status code: ${response.statusCode.toString()}"); showStatusCodeError(context, response.statusCode);
return false; return false;
} else { } else {
var data = json.decode(response.body); var data = json.decode(response.body);
if (!data.containsKey("token")) { if (!data.containsKey("token")) {
await showServerError(context, "No token provided in response"); showServerError(
context,
"Missing Token",
"Access token missing from response"
);
return false; return false;
} }
@ -312,10 +306,50 @@ class InvenTreeAPI {
_connected = true; _connected = true;
// Ok, probably pretty good...
return true; return true;
}; };
} }
bool disconnectFromServer() {
print("InvenTreeAPI().disconnectFromServer()");
_connected = false;
_connecting = false;
_token = '';
profile = null;
}
Future<bool> connectToServer(BuildContext context) async {
// Ensure server is first disconnected
disconnectFromServer();
// Load selected profile
profile = await UserProfileDBManager().getSelectedProfile();
print("API Profile: ${profile.toString()}");
if (profile == null) {
await showErrorDialog(
context,
"Select Profile",
"User profile not selected"
);
return false;
}
_connecting = true;
bool result = await _connect(context);
print("_connect() returned result: ${result}");
_connecting = false;
return result;
}
// Perform a PATCH request // Perform a PATCH request
Future<http.Response> patch(String url, {Map<String, String> body}) async { Future<http.Response> patch(String url, {Map<String, String> body}) async {
var _url = makeApiUrl(url); var _url = makeApiUrl(url);
@ -399,7 +433,9 @@ class InvenTreeAPI {
Map<String, String> defaultHeaders() { Map<String, String> defaultHeaders() {
var headers = Map<String, String>(); var headers = Map<String, String>();
headers[HttpHeaders.authorizationHeader] = _authorizationHeader(); if (profile != null) {
headers[HttpHeaders.authorizationHeader] = _authorizationHeader(profile.username, profile.password);
}
return headers; return headers;
} }
@ -410,11 +446,11 @@ class InvenTreeAPI {
return headers; return headers;
} }
String _authorizationHeader() { String _authorizationHeader(String username, String password) {
if (_token.isNotEmpty) { if (_token.isNotEmpty) {
return "Token $_token"; return "Token $_token";
} else { } else {
return "Basic " + base64Encode(utf8.encode('$_username:$_password')); return "Basic " + base64Encode(utf8.encode('${username}:${password}'));
} }
} }

View File

@ -7,6 +7,7 @@ import 'package:qr_code_scanner/qr_code_scanner.dart';
import 'package:InvenTree/inventree/stock.dart'; import 'package:InvenTree/inventree/stock.dart';
import 'package:InvenTree/inventree/part.dart'; import 'package:InvenTree/inventree/part.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:InvenTree/api.dart'; import 'package:InvenTree/api.dart';
@ -65,14 +66,11 @@ class BarcodeHandler {
); );
} }
Future<void> processBarcode(BuildContext context, QRViewController _controller, String barcode, {String url = "barcode/", bool show_dialog = false}) { Future<void> processBarcode(BuildContext context, QRViewController _controller, String barcode, {String url = "barcode/"}) {
this._context = context; this._context = context;
this._controller = _controller; this._controller = _controller;
print("Scanned barcode data: ${barcode}"); print("Scanned barcode data: ${barcode}");
if (show_dialog) {
showProgressDialog(context, "Scanning", "Sending barcode data to server");
}
// Send barcode request to server // Send barcode request to server
InvenTreeAPI().post( InvenTreeAPI().post(
@ -82,10 +80,6 @@ class BarcodeHandler {
} }
).then((var response) { ).then((var response) {
if (show_dialog) {
hideProgressDialog(context);
}
if (response.statusCode != 200) { if (response.statusCode != 200) {
showErrorDialog( showErrorDialog(
context, context,
@ -118,10 +112,6 @@ class BarcodeHandler {
Duration(seconds: 5) Duration(seconds: 5)
).catchError((error) { ).catchError((error) {
if (show_dialog) {
hideProgressDialog(context);
}
showErrorDialog( showErrorDialog(
context, context,
"Error", "Error",
@ -207,7 +197,7 @@ class BarcodeScanHandler extends BarcodeHandler {
showDialog( showDialog(
context: _context, context: _context,
child: SimpleDialog( child: SimpleDialog(
title: Text("Unknown response"), title: Text(I18N.of(_context).unknownResponse),
children: <Widget>[ children: <Widget>[
ListTile( ListTile(
title: Text("Response data"), title: Text("Response data"),

View File

@ -1,10 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:InvenTree/api.dart'; import 'package:InvenTree/api.dart';
import 'package:InvenTree/widget/dialogs.dart'; import 'package:InvenTree/widget/dialogs.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
@ -105,24 +108,24 @@ class InvenTreeModel {
/* /*
* Reload this object, by requesting data from the server * Reload this object, by requesting data from the server
*/ */
Future<bool> reload(BuildContext context, {bool dialog = false}) async { Future<bool> reload(BuildContext context) async {
if (dialog) {
showProgressDialog(context, "Refreshing data", "Refreshing data for ${NAME}");
}
var response = await api.get(url, params: defaultGetFilters()) var response = await api.get(url, params: defaultGetFilters())
.timeout(Duration(seconds: 10)) .timeout(Duration(seconds: 10))
.catchError((e) { .catchError((e) {
if (dialog) { if (e is SocketException) {
hideProgressDialog(context); showServerError(
context,
I18N.of(context).connectionRefused,
e.toString()
);
} }
else if (e is TimeoutException) {
if (e is TimeoutException) { showTimeoutError(context);
showErrorDialog(context, "Timeout", "No response from server");
} else { } else {
showErrorDialog(context, "Error", e.toString()); // Re-throw the error (Sentry will catch)
throw e;
} }
return null; return null;
@ -132,11 +135,8 @@ class InvenTreeModel {
return false; return false;
} }
if (dialog) {
hideProgressDialog(context);
}
if (response.statusCode != 200) { if (response.statusCode != 200) {
showStatusCodeError(context, response.statusCode);
print("Error retrieving data"); print("Error retrieving data");
return false; return false;
} }
@ -149,7 +149,7 @@ class InvenTreeModel {
} }
// POST data to update the model // POST data to update the model
Future<bool> update(BuildContext context, {Map<String, String> values, bool show_dialog = false}) async { Future<bool> update(BuildContext context, {Map<String, String> values}) async {
var addr = path.join(URL, pk.toString()); var addr = path.join(URL, pk.toString());
@ -157,22 +157,21 @@ class InvenTreeModel {
addr += "/"; addr += "/";
} }
if (show_dialog) {
showProgressDialog(context, "Updating ${NAME}", "Sending data to server");
}
var response = await api.patch(addr, body: values) var response = await api.patch(addr, body: values)
.timeout(Duration(seconds: 10)) .timeout(Duration(seconds: 10))
.catchError((e) { .catchError((e) {
if (show_dialog) { if (e is SocketException) {
hideProgressDialog(context); showServerError(
} context,
I18N.of(context).connectionRefused,
if (e is TimeoutException) { e.toString()
showErrorDialog(context, "Timeout", "No response from server"); );
} else if (e is TimeoutException) {
showTimeoutError(context);
} else { } else {
showErrorDialog(context, "Error", e.toString()); // Re-throw the error, let Sentry report it
throw e;
} }
return null; return null;
@ -180,12 +179,8 @@ class InvenTreeModel {
if (response == null) return false; if (response == null) return false;
if (show_dialog) {
hideProgressDialog(context);
}
if (response.statusCode != 200) { if (response.statusCode != 200) {
print("Error updating ${NAME}: Status code ${response.statusCode}"); showStatusCodeError(context, response.statusCode);
return false; return false;
} }
@ -194,7 +189,7 @@ class InvenTreeModel {
} }
// Return the detail view for the associated pk // Return the detail view for the associated pk
Future<InvenTreeModel> get(BuildContext context, int pk, {Map<String, String> filters, bool dialog = false}) async { Future<InvenTreeModel> get(BuildContext context, int pk, {Map<String, String> filters}) async {
// TODO - Add "timeout" // TODO - Add "timeout"
// TODO - Add error catching // TODO - Add error catching
@ -216,22 +211,18 @@ class InvenTreeModel {
print("GET: $addr ${params.toString()}"); print("GET: $addr ${params.toString()}");
if (dialog) {
showProgressDialog(context, "Requesting Data", "Requesting ${NAME} data from server");
}
var response = await api.get(addr, params: params) var response = await api.get(addr, params: params)
.timeout(Duration(seconds: 10)) .timeout(Duration(seconds: 10))
.catchError((e) { .catchError((e) {
if (dialog) { if (e is SocketException) {
hideProgressDialog(context); showServerError(context, I18N.of(context).connectionRefused, e.toString());
} }
else if (e is TimeoutException) {
if (e is TimeoutException) { showTimeoutError(context);
showErrorDialog(context, "Timeout", "No response from server");
} else { } else {
showErrorDialog(context, "Error", e.toString()); // Re-throw the error (handled by Sentry)
throw e;
} }
return null; return null;
}); });
@ -240,10 +231,8 @@ class InvenTreeModel {
return null; return null;
} }
hideProgressDialog(context);
if (response.statusCode != 200) { if (response.statusCode != 200) {
print("Error retrieving data"); showStatusCodeError(context, response.statusCode);
return null; return null;
} }
@ -266,11 +255,24 @@ class InvenTreeModel {
InvenTreeModel _model; InvenTreeModel _model;
await api.post(URL, body: data) await api.post(URL, body: data).timeout(Duration(seconds: 10)).catchError((e) {
.timeout(Duration(seconds: 5)) print("Error during CREATE");
.catchError((e) {
print("Error creating new ${NAME}:");
print(e.toString()); print(e.toString());
if (e is SocketException) {
showServerError(
context,
I18N.of(context).connectionRefused,
e.toString()
);
}
else if (e is TimeoutException) {
showTimeoutError(context);
} else {
// Re-throw the error (Sentry will catch)
throw e;
}
return null; return null;
}) })
.then((http.Response response) { .then((http.Response response) {
@ -279,8 +281,7 @@ class InvenTreeModel {
var decoded = json.decode(response.body); var decoded = json.decode(response.body);
_model = createFromJson(decoded); _model = createFromJson(decoded);
} else { } else {
print("Error creating object: Status Code ${response.statusCode}"); showStatusCodeError(context, response.statusCode);
print(response.body);
} }
}); });
@ -288,7 +289,7 @@ class InvenTreeModel {
} }
// Return list of objects from the database, with optional filters // Return list of objects from the database, with optional filters
Future<List<InvenTreeModel>> list(BuildContext context, {Map<String, String> filters, bool dialog=false}) async { Future<List<InvenTreeModel>> list(BuildContext context, {Map<String, String> filters}) async {
if (filters == null) { if (filters == null) {
filters = {}; filters = {};
@ -307,20 +308,19 @@ class InvenTreeModel {
// TODO - Add "timeout" // TODO - Add "timeout"
// TODO - Add error catching // TODO - Add error catching
if (dialog) {
showProgressDialog(context, "Requesting Data", "Requesting ${NAME} data from server");
}
var response = await api.get(URL, params:params) var response = await api.get(URL, params:params)
.timeout(Duration(seconds: 10)) .timeout(Duration(seconds: 10))
.catchError((e) { .catchError((e) {
if (dialog) { if (e is SocketException) {
hideProgressDialog(context); showServerError(
context,
I18N.of(context).connectionRefused,
e.toString()
);
} }
else if (e is TimeoutException) {
if (e is TimeoutException) { showTimeoutError(context);
showErrorDialog(context, "Timeout", "No response from server");
} else { } else {
// Re-throw the error // Re-throw the error
throw e; throw e;
@ -333,15 +333,13 @@ class InvenTreeModel {
return null; return null;
} }
if (dialog) {
hideProgressDialog(context);
}
// A list of "InvenTreeModel" items // A list of "InvenTreeModel" items
List<InvenTreeModel> results = new List<InvenTreeModel>(); List<InvenTreeModel> results = new List<InvenTreeModel>();
if (response.statusCode != 200) { if (response.statusCode != 200) {
print("Error retreiving data"); showStatusCodeError(context, response.statusCode);
// Return empty list
return results; return results;
} }
@ -399,8 +397,6 @@ class InvenTreeModel {
return true; return true;
} }
} }

View File

@ -155,13 +155,12 @@ class InvenTreePart extends InvenTreeModel {
// Request stock items for this part // Request stock items for this part
Future<void> getStockItems(BuildContext context, {bool showDialog=false}) async { Future<void> getStockItems(BuildContext context, {bool showDialog=false}) async {
InvenTreeStockItem().list( await InvenTreeStockItem().list(
context, context,
filters: { filters: {
"part": "${pk}", "part": "${pk}",
"in_stock": "true", "in_stock": "true",
}, },
dialog: showDialog,
).then((var items) { ).then((var items) {
stockItems.clear(); stockItems.clear();
@ -186,7 +185,6 @@ class InvenTreePart extends InvenTreeModel {
filters: { filters: {
"part": "${pk}", "part": "${pk}",
}, },
dialog: showDialog,
).then((var templates) { ).then((var templates) {
testingTemplates.clear(); testingTemplates.clear();
@ -205,6 +203,15 @@ class InvenTreePart extends InvenTreeModel {
// Get the stock count for this Part // 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 {
if (inStock == inStock.toInt()) {
return inStock.toInt().toString();
} else {
return inStock.toString();
}
}
// Get the number of units being build for this Part // 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');

View File

@ -97,7 +97,6 @@ class InvenTreeStockItem extends InvenTreeModel {
filters: { filters: {
"part": "${partId}", "part": "${partId}",
}, },
dialog: showDialog,
).then((var templates) { ).then((var templates) {
testTemplates.clear(); testTemplates.clear();
@ -113,7 +112,7 @@ class InvenTreeStockItem extends InvenTreeModel {
int get testResultCount => testResults.length; int get testResultCount => testResults.length;
Future<void> getTestResults(BuildContext context, {bool showDialog=false}) async { Future<void> getTestResults(BuildContext context) async {
await InvenTreeStockItemTestResult().list( await InvenTreeStockItemTestResult().list(
context, context,
@ -121,7 +120,6 @@ class InvenTreeStockItem extends InvenTreeModel {
"stock_item": "${pk}", "stock_item": "${pk}",
"user_detail": "true", "user_detail": "true",
}, },
dialog: showDialog,
).then((var results) { ).then((var results) {
testResults.clear(); testResults.clear();
@ -287,6 +285,11 @@ class InvenTreeStockItem extends InvenTreeModel {
return 'SN ${serialNumber}'; return 'SN ${serialNumber}';
} }
// Is an integer?
if (quantity.toInt() == quantity) {
return '${quantity.toInt()}';
}
return '${quantity}'; return '${quantity}';
} }

@ -1 +1 @@
Subproject commit 58e2c5027b481a3a620c27b90ae20b888a02bd96 Subproject commit 90f3bbf1fae86efd0bb0686bef12452a09507669

View File

@ -8,12 +8,14 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dsn.dart'; import 'dsn.dart';
import 'preferences.dart';
import 'package:sentry/sentry.dart'; import 'package:sentry/sentry.dart';
// Use the secret app key // Use the secret app key
final SentryClient _sentry = SentryClient(dsn: SENTRY_DSN_KEY); final SentryClient _sentry = SentryClient(
SentryOptions(
dsn: SENTRY_DSN_KEY,
));
bool isInDebugMode() { bool isInDebugMode() {
bool inDebugMode = false; bool inDebugMode = false;
@ -31,13 +33,15 @@ Future<void> _reportError(dynamic error, dynamic stackTrace) async {
print(stackTrace); print(stackTrace);
return; return;
} else { } else {
// Send the Exception and Stacktrace to Sentry in Production mode. try {
_sentry.captureException( await _sentry.captureException(
exception: error, error,
stackTrace: stackTrace, stackTrace: stackTrace
); );
} catch (e) {
print("Sending error to sentry.io"); print("Sending error report to sentry.io failed: ${e}");
print("Original error: ${error}");
}
} }
} }
@ -47,12 +51,8 @@ void main() async {
runZoned<Future<void>>(() async { runZoned<Future<void>>(() async {
runApp(InvenTreeApp()); runApp(InvenTreeApp());
}, onError: (error, stackTrace) { }, onError: _reportError
// Whenever an error occurs, call the `_reportError` function. This sends );
// Dart errors to the dev console or Sentry depending on the environment.
_reportError(error, stackTrace);
});
} }
class InvenTreeApp extends StatelessWidget { class InvenTreeApp extends StatelessWidget {

View File

@ -1,8 +1,57 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'api.dart'; import 'api.dart';
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';
/*
* Class for storing InvenTree preferences in a NoSql DB
*/
class InvenTreePreferencesDB {
static final InvenTreePreferencesDB _singleton = InvenTreePreferencesDB._();
static InvenTreePreferencesDB get instance => _singleton;
InvenTreePreferencesDB._();
Completer<Database> _dbOpenCompleter;
Future<Database> get database async {
// If completer is null, AppDatabaseClass is newly instantiated, so database is not yet opened
if (_dbOpenCompleter == null) {
_dbOpenCompleter = Completer();
// Calling _openDatabase will also complete the completer with database instance
_openDatabase();
}
// If the database is already opened, awaiting the future will happen instantly.
// Otherwise, awaiting the returned future will take some time - until complete() is called
// on the Completer in _openDatabase() below.
return _dbOpenCompleter.future;
}
Future _openDatabase() async {
// Get a platform-specific directory where persistent app data can be stored
final appDocumentDir = await getApplicationDocumentsDirectory();
print("Documents Dir: ${appDocumentDir.toString()}");
print("Path: ${appDocumentDir.path}");
// Path with the form: /platform-specific-directory/demo.db
final dbPath = join(appDocumentDir.path, 'InvenTreeSettings.db');
final database = await databaseFactoryIo.openDatabase(dbPath);
// Any code awaiting the Completer's future will now start executing
_dbOpenCompleter.complete(database);
}
}
class InvenTreePreferences { class InvenTreePreferences {
static const String _SERVER = 'server'; static const String _SERVER = 'server';
@ -34,30 +83,4 @@ class InvenTreePreferences {
} }
InvenTreePreferences._internal(); InvenTreePreferences._internal();
// Load saved login details, and attempt connection
void loadLoginDetails(BuildContext context) async {
print("Loading login details");
SharedPreferences prefs = await SharedPreferences.getInstance();
var server = prefs.getString(_SERVER) ?? '';
var username = prefs.getString(_USERNAME) ?? '';
var password = prefs.getString(_PASSWORD) ?? '';
await InvenTreeAPI().connectToServer(context, server, username, password);
}
void saveLoginDetails(BuildContext context, String server, String username, String password) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_SERVER, server);
await prefs.setString(_USERNAME, username);
await prefs.setString(_PASSWORD, password);
// Reconnect the API
await InvenTreeAPI().connectToServer(context, server, username, password);
}
} }

View File

@ -15,48 +15,96 @@ class InvenTreeAboutWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( List<Widget> tiles = [];
appBar: AppBar(
title: Text("About InvenTree"), tiles.add(
), ListTile(
body: ListView( title: Text(
children: <Widget>[ I18N.of(context).serverDetails,
ListTile( style: TextStyle(fontWeight: FontWeight.bold),
title: Text(I18N.of(context).serverDetails), ),
), )
);
if (InvenTreeAPI().isConnected()) {
tiles.add(
ListTile( ListTile(
title: Text(I18N.of(context).address), title: Text(I18N.of(context).address),
subtitle: Text(InvenTreeAPI().baseUrl.isNotEmpty ? InvenTreeAPI().baseUrl : "Not connected"), subtitle: Text(InvenTreeAPI().baseUrl.isNotEmpty ? InvenTreeAPI().baseUrl : "Not connected"),
),
ListTile(
title: Text(I18N.of(context).version),
subtitle: Text(InvenTreeAPI().version.isNotEmpty ? InvenTreeAPI().version : "Not connected"),
),
ListTile(
title: Text("Server Instance"),
subtitle: Text(InvenTreeAPI().instance.isNotEmpty ? InvenTreeAPI().instance : "Not connected"),
),
Divider(),
ListTile(
title: Text(I18N.of(context).appDetails),
),
ListTile(
title: Text(I18N.of(context).name),
subtitle: Text("${info.appName}"),
),
ListTile(
title: Text("Package Name"),
subtitle: Text("${info.packageName}"),
),
ListTile(
title: Text(I18N.of(context).version),
subtitle: Text("${info.version}"),
),
ListTile(
title: Text(I18N.of(context).build),
subtitle: Text("${info.buildNumber}"),
) )
], );
tiles.add(
ListTile(
title: Text(I18N.of(context).version),
subtitle: Text(InvenTreeAPI().version.isNotEmpty ? InvenTreeAPI().version : "Not connected"),
)
);
tiles.add(
ListTile(
title: Text(I18N.of(context).serverInstance),
subtitle: Text(InvenTreeAPI().instance.isNotEmpty ? InvenTreeAPI().instance : "Not connected"),
)
);
} else {
tiles.add(
ListTile(
title: Text(I18N.of(context).notConnected),
subtitle: Text(
I18N.of(context).serverNotConnected,
style: TextStyle(fontStyle: FontStyle.italic),
)
)
);
}
tiles.add(
ListTile(
title: Text(
I18N.of(context).appDetails,
style: TextStyle(fontWeight: FontWeight.bold),
),
)
);
tiles.add(
ListTile(
title: Text(I18N.of(context).name),
subtitle: Text("${info.appName}"),
)
);
tiles.add(
ListTile(
title: Text(I18N.of(context).packageName),
subtitle: Text("${info.packageName}"),
)
);
tiles.add(
ListTile(
title: Text(I18N.of(context).version),
subtitle: Text("${info.version}"),
)
);
tiles.add(
ListTile(
title: Text(I18N.of(context).build),
subtitle: Text("${info.buildNumber}"),
)
);
return Scaffold(
appBar: AppBar(
title: Text(I18N.of(context).appAbout),
),
body: ListView(
children: ListTile.divideTiles(
context: context,
tiles: tiles,
).toList(),
) )
); );
} }

View File

@ -1,37 +1,141 @@
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:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../api.dart'; import '../api.dart';
import '../preferences.dart'; import '../preferences.dart';
import '../user_profile.dart';
class InvenTreeLoginSettingsWidget extends StatefulWidget { class InvenTreeLoginSettingsWidget extends StatefulWidget {
final SharedPreferences _preferences;
InvenTreeLoginSettingsWidget(this._preferences) : super();
@override @override
_InvenTreeLoginSettingsState createState() => _InvenTreeLoginSettingsState(_preferences); _InvenTreeLoginSettingsState createState() => _InvenTreeLoginSettingsState();
} }
class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> { class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
final GlobalKey<_InvenTreeLoginSettingsState> _loginKey = GlobalKey<_InvenTreeLoginSettingsState>();
final GlobalKey<FormState> _formKey = new GlobalKey<FormState>(); final GlobalKey<FormState> _formKey = new GlobalKey<FormState>();
final SharedPreferences _preferences; final GlobalKey<FormState> _addProfileKey = new GlobalKey<FormState>();
String _server = ''; List<UserProfile> profiles;
String _username = '';
String _password = '';
_InvenTreeLoginSettingsState(this._preferences) : super() { _InvenTreeLoginSettingsState() {
_server = _preferences.getString('server') ?? ''; _reload();
_username = _preferences.getString('username') ?? '';
_password = _preferences.getString('password') ?? '';
} }
void _reload() async {
profiles = await UserProfileDBManager().getAllProfiles();
setState(() {
});
}
void _editProfile(BuildContext context, {UserProfile userProfile, bool createNew = false}) {
var _name;
var _server;
var _username;
var _password;
UserProfile profile;
if (userProfile != null) {
profile = userProfile;
}
showFormDialog(
context,
I18N.of(context).profileAdd,
key: _addProfileKey,
actions: <Widget> [
FlatButton(
child: Text(I18N.of(context).cancel),
onPressed: () {
Navigator.of(context).pop();
}
),
FlatButton(
child: Text(I18N.of(context).save),
onPressed: () {
if (_addProfileKey.currentState.validate()) {
_addProfileKey.currentState.save();
if (createNew) {
// TODO - create the new profile...
UserProfile profile = UserProfile(
name: _name,
server: _server,
username: _username,
password: _password
);
_addProfile(profile);
} else {
profile.name = _name;
profile.server = _server;
profile.username = _username;
profile.password = _password;
_updateProfile(profile);
}
}
}
)
],
fields: <Widget> [
StringField(
label: I18N.of(context).name,
hint: "Enter profile name",
initial: createNew ? '' : profile.name,
onSaved: (value) => _name = value,
validator: _validateProfileName,
),
StringField(
label: I18N.of(context).server,
hint: "http[s]://<server>:<port>",
initial: createNew ? '' : profile.server,
validator: _validateServer,
onSaved: (value) => _server = value,
),
StringField(
label: I18N.of(context).username,
hint: "Enter username",
initial: createNew ? '' : profile.username,
onSaved: (value) => _username = value,
validator: _validateUsername,
),
StringField(
label: I18N.of(context).password,
hint: "Enter password",
initial: createNew ? '' : profile.password,
onSaved: (value) => _password = value,
validator: _validatePassword,
)
]
);
}
String _validateProfileName(String value) {
if (value.isEmpty) {
return 'Profile name cannot be empty';
}
// TODO: Check if a profile already exists with ths name
return null;
}
String _validateServer(String value) { String _validateServer(String value) {
@ -43,6 +147,8 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
return 'Server must start with http[s]'; return 'Server must start with http[s]';
} }
// TODO: URL validator
return null; return null;
} }
@ -62,12 +168,94 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
return null; return null;
} }
void _save(BuildContext context) async { void _selectProfile(BuildContext context, UserProfile profile) async {
if (_formKey.currentState.validate()) {
_formKey.currentState.save();
await InvenTreePreferences().saveLoginDetails(context, _server, _username, _password); // Disconnect InvenTree
InvenTreeAPI().disconnectFromServer();
await UserProfileDBManager().selectProfile(profile.key);
_reload();
// Attempt server login (this will load the newly selected profile
InvenTreeAPI().connectToServer(_loginKey.currentContext).then((result) {
_reload();
});
_reload();
}
void _deleteProfile(UserProfile profile) async {
await UserProfileDBManager().deleteProfile(profile);
// Close the dialog
Navigator.of(context).pop();
_reload();
if (InvenTreeAPI().isConnected() && profile.key == InvenTreeAPI().profile.key) {
InvenTreeAPI().disconnectFromServer();
}
}
void _updateProfile(UserProfile profile) async {
await UserProfileDBManager().updateProfile(profile);
// Dismiss the dialog
Navigator.of(context).pop();
_reload();
if (InvenTreeAPI().isConnected() && profile.key == InvenTreeAPI().profile.key) {
// Attempt server login (this will load the newly selected profile
InvenTreeAPI().connectToServer(_loginKey.currentContext).then((result) {
_reload();
});
}
}
void _addProfile(UserProfile profile) async {
await UserProfileDBManager().addProfile(profile);
// Dismiss the create dialog
Navigator.of(context).pop();
_reload();
}
Widget _getProfileIcon(UserProfile profile) {
// Not selected? No icon for you!
if (profile == null || !profile.selected) return null;
// Selected, but (for some reason) not the same as the API...
if (InvenTreeAPI().profile == null || InvenTreeAPI().profile.key != profile.key) {
return FaIcon(
FontAwesomeIcons.questionCircle,
color: Color.fromRGBO(250, 150, 50, 1)
);
}
// Reflect the connection status of the server
if (InvenTreeAPI().isConnected()) {
return FaIcon(
FontAwesomeIcons.checkCircle,
color: Color.fromRGBO(50, 250, 50, 1)
);
} else if (InvenTreeAPI().isConnecting()) {
return Spinner(
icon: FontAwesomeIcons.spinner,
color: Color.fromRGBO(50, 50, 250, 1),
);
} else {
return FaIcon(
FontAwesomeIcons.timesCircle,
color: Color.fromRGBO(250, 50, 50, 1),
);
} }
} }
@ -76,64 +264,92 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
final Size screenSize = MediaQuery.of(context).size; final Size screenSize = MediaQuery.of(context).size;
return Scaffold( List<Widget> children = [];
appBar: AppBar(
title: Text("Login Settings"), if (profiles != null && profiles.length > 0) {
), for (int idx = 0; idx < profiles.length; idx++) {
body: new Container( UserProfile profile = profiles[idx];
padding: new EdgeInsets.all(20.0),
child: new Form( children.add(ListTile(
key: _formKey, title: Text(
child: new ListView( profile.name,
children: <Widget>[ ),
Text(I18N.of(context).serverAddress), tileColor: profile.selected ? Color.fromRGBO(0, 0, 0, 0.05) : null,
new TextFormField( subtitle: Text("${profile.server}"),
initialValue: _server, trailing: _getProfileIcon(profile),
decoration: InputDecoration( onTap: () {
hintText: "127.0.0.1:8000", _selectProfile(context, profile);
), },
validator: _validateServer, onLongPress: () {
onSaved: (String value) { showDialog(
_server = value; context: context,
}, builder: (BuildContext context) {
), return SimpleDialog(
Divider(), title: Text(profile.name),
Text(I18N.of(context).accountDetails), children: <Widget>[
TextFormField( Divider(),
initialValue: _username, SimpleDialogOption(
decoration: InputDecoration( onPressed: () {
hintText: I18N.of(context).username, Navigator.of(context).pop();
labelText: I18N.of(context).username, _selectProfile(context, profile);
), },
validator: _validateUsername, child: Text(I18N.of(context).profileSelect),
onSaved: (String value) { ),
_username = value; SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
_editProfile(context, userProfile: profile);
},
child: Text(I18N.of(context).profileEdit),
),
SimpleDialogOption(
onPressed: () {
// Navigator.of(context, rootNavigator: true).pop();
confirmationDialog(
context,
I18N.of(context).delete,
"Delete this profile?",
onAccept: () {
_deleteProfile(profile);
}
);
},
child: Text(I18N.of(context).profileDelete),
)
],
);
} }
), );
TextFormField( },
initialValue: _password, ));
obscureText: true, }
decoration: InputDecoration( } else {
hintText: I18N.of(context).password, // No profile available!
labelText: I18N.of(context).password, children.add(
), ListTile(
validator: _validatePassword, title: Text("No profiles available"),
onSaved: (String value) {
_password = value;
},
),
Container(
width: screenSize.width,
child: RaisedButton(
child: Text(I18N.of(context).save),
onPressed: () {
_save(context);
}
)
)
],
)
) )
);
}
return Scaffold(
key: _loginKey,
appBar: AppBar(
title: Text(I18N.of(context).profileSelect),
),
body: Container(
child: ListView(
children: ListTile.divideTiles(
context: context,
tiles: children
).toList(),
)
),
floatingActionButton: FloatingActionButton(
child: Icon(FontAwesomeIcons.plus),
onPressed: () {
_editProfile(context, createNew: true);
},
) )
); );
} }

View File

@ -1,6 +1,8 @@
import 'package:InvenTree/settings/about.dart'; import 'package:InvenTree/settings/about.dart';
import 'package:InvenTree/settings/login.dart'; import 'package:InvenTree/settings/login.dart';
import 'package:InvenTree/settings/release.dart'; import 'package:InvenTree/settings/release.dart';
import 'package:InvenTree/user_profile.dart';
import 'package:InvenTree/preferences.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -28,37 +30,39 @@ class _InvenTreeSettingsState extends State<InvenTreeSettingsWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("InvenTree Settings"), title: Text(I18N.of(context).settings),
), ),
body: Center( body: Center(
child: ListView( child: ListView(
children: <Widget>[ children: ListTile.divideTiles(
ListTile( context: context,
title: Text(I18N.of(context).serverSettings), tiles: <Widget>[
subtitle: Text("Configure server and login settings"), ListTile(
leading: FaIcon(FontAwesomeIcons.server), title: Text(I18N.of(context).profile),
onTap: _editServerSettings, subtitle: Text("Configure user profile settings"),
), leading: FaIcon(FontAwesomeIcons.user),
Divider(), onTap: _editServerSettings,
ListTile( ),
title: Text(I18N.of(context).about), ListTile(
subtitle: Text(I18N.of(context).appDetails), title: Text(I18N.of(context).about),
leading: FaIcon(FontAwesomeIcons.infoCircle), subtitle: Text(I18N.of(context).appDetails),
onTap: _about, leading: FaIcon(FontAwesomeIcons.infoCircle),
), onTap: _about,
ListTile( ),
title: Text(I18N.of(context).releaseNotes), ListTile(
subtitle: Text("Display app release notes"), title: Text(I18N.of(context).releaseNotes),
leading: FaIcon(FontAwesomeIcons.fileAlt), subtitle: Text("Display app release notes"),
onTap: _releaseNotes, leading: FaIcon(FontAwesomeIcons.fileAlt),
), onTap: _releaseNotes,
ListTile( ),
title: Text(I18N.of(context).reportBug), ListTile(
subtitle: Text("Report bug or suggest new feature"), title: Text(I18N.of(context).reportBug),
leading: FaIcon(FontAwesomeIcons.bug), subtitle: Text("Report bug or suggest new feature"),
onTap: null, leading: FaIcon(FontAwesomeIcons.bug),
), onTap: null,
], ),
]
).toList()
) )
) )
); );
@ -68,7 +72,9 @@ class _InvenTreeSettingsState extends State<InvenTreeSettingsWidget> {
var prefs = await SharedPreferences.getInstance(); var prefs = await SharedPreferences.getInstance();
Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget(prefs))); List<UserProfile> profiles = await UserProfileDBManager().getAllProfiles();
Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget()));
} }
void _about() async { void _about() async {

177
lib/user_profile.dart Normal file
View File

@ -0,0 +1,177 @@
/*
* Class for InvenTree user / login details
*/
import 'package:sembast/sembast.dart';
import 'preferences.dart';
class UserProfile {
UserProfile({
this.key,
this.name,
this.server,
this.username,
this.password,
this.selected,
});
// ID of the profile
int key;
// Name of the user profile
String name;
// Base address of the InvenTree server
String server;
// Username
String username;
// Password
String password;
bool selected = false;
// User ID (will be provided by the server on log-in)
int user_id;
factory UserProfile.fromJson(int key, Map<String, dynamic> json, bool isSelected) => UserProfile(
key: key,
name: json['name'],
server: json['server'],
username: json['username'],
password: json['password'],
selected: isSelected,
);
Map<String, dynamic> toJson() => {
"name": name,
"server": server,
"username": username,
"password": password,
};
@override
String toString() {
return "<${key}> ${name} : ${server} - ${username}:${password}";
}
}
class UserProfileDBManager {
final store = StoreRef("profiles");
Future<Database> get _db async => await InvenTreePreferencesDB.instance.database;
Future<bool> profileNameExists(String name) async {
final finder = Finder(filter: Filter.equals("name", name));
final profiles = await store.find(await _db, finder: finder);
return profiles.length > 0;
}
Future addProfile(UserProfile profile) async {
// Check if a profile already exists with the name
final bool exists = await profileNameExists(profile.name);
if (exists) {
print("UserProfile '${profile.name}' already exists");
return;
}
int key = await store.add(await _db, profile.toJson());
print("Added user profile <${key}> - '${profile.name}'");
// Record the key
profile.key = key;
}
Future selectProfile(int key) async {
/*
* Mark the particular profile as selected
*/
final result = await store.record("selected").put(await _db, key);
return result;
}
Future updateProfile(UserProfile profile) async {
if (profile.key == null) {
await addProfile(profile);
return;
}
final result = await store.record(profile.key).update(await _db, profile.toJson());
print("Updated user profile <${profile.key}> - '${profile.name}'");
return result;
}
Future deleteProfile(UserProfile profile) async {
final finder = Finder(filter: Filter.equals("name", profile.name));
await store.record(profile.key).delete(await _db);
print("Deleted user profile <${profile.key}> - '${profile.name}'");
}
Future<UserProfile> getSelectedProfile() async {
/*
* Return the currently selected profile.
*
* key should match the "selected" property
*/
final selected = await store.record("selected").get(await _db);
final profiles = await store.find(await _db);
List<UserProfile> profileList = new List<UserProfile>();
for (int idx = 0; idx < profiles.length; idx++) {
if (profiles[idx].key is int && profiles[idx].key == selected) {
return UserProfile.fromJson(
profiles[idx].key,
profiles[idx].value,
profiles[idx].key == selected,
);
}
}
return null;
}
/*
* Return all user profile objects
*/
Future<List<UserProfile>> getAllProfiles() async {
final selected = await store.record("selected").get(await _db);
final profiles = await store.find(await _db);
List<UserProfile> profileList = new List<UserProfile>();
for (int idx = 0; idx < profiles.length; idx++) {
if (profiles[idx].key is int) {
profileList.add(
UserProfile.fromJson(
profiles[idx].key,
profiles[idx].value,
profiles[idx].key == selected,
));
}
}
return profileList;
}
}

View File

@ -231,9 +231,10 @@ class SubcategoryList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.builder( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: ClampingScrollPhysics(), physics: ClampingScrollPhysics(),
separatorBuilder: (_, __) => const Divider(height: 3),
itemBuilder: _build, itemCount: _categories.length); itemBuilder: _build, itemCount: _categories.length);
} }
} }
@ -267,7 +268,7 @@ class PartList extends StatelessWidget {
return ListTile( return ListTile(
title: Text("${part.name}"), title: Text("${part.name}"),
subtitle: Text("${part.description}"), subtitle: Text("${part.description}"),
trailing: Text("${part.inStock}"), trailing: Text("${part.inStockString}"),
leading: InvenTreeAPI().getImage( leading: InvenTreeAPI().getImage(
part.thumbnail, part.thumbnail,
width: 40, width: 40,
@ -282,9 +283,10 @@ class PartList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.builder( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: ClampingScrollPhysics(), physics: ClampingScrollPhysics(),
separatorBuilder: (_, __) => const Divider(height: 3),
itemBuilder: _build, itemCount: _parts.length); itemBuilder: _build, itemCount: _parts.length);
} }
} }

View File

@ -3,6 +3,54 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
Future<void> confirmationDialog(BuildContext context, String title, String text, {String acceptText, String rejectText, Function onAccept, Function onReject}) async {
if (acceptText == null || acceptText.isEmpty) {
acceptText = I18N.of(context).ok;
}
if (rejectText == null || rejectText.isEmpty) {
rejectText = I18N.of(context).cancel;
}
AlertDialog dialog = AlertDialog(
title: ListTile(
title: Text(title),
leading: FaIcon(FontAwesomeIcons.questionCircle),
),
content: Text(text),
actions: [
FlatButton(
child: Text(rejectText),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
if (onReject != null) {
onReject();
}
}
),
FlatButton(
child: Text(acceptText),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
if (onAccept != null) {
onAccept();
}
}
)
],
);
showDialog(
context: context,
builder: (BuildContext context) {
return dialog;
}
);
}
void showMessage(BuildContext context, String message) { void showMessage(BuildContext context, String message) {
Scaffold.of(context).showSnackBar(SnackBar( Scaffold.of(context).showSnackBar(SnackBar(
@ -45,23 +93,53 @@ Future<void> showErrorDialog(BuildContext context, String title, String descript
showDialog( showDialog(
context: context, context: context,
child: SimpleDialog( builder: (dialogContext) {
title: ListTile( return SimpleDialog(
title: Text(error), title: ListTile(
leading: FaIcon(icon), title: Text(error),
), leading: FaIcon(icon),
children: <Widget>[ ),
ListTile( children: <Widget>[
title: Text(title), ListTile(
subtitle: Text(description) title: Text(title),
) subtitle: Text(description)
] )
) ]
).then((value) { );
if (onDismissed != null) { }).then((value) {
onDismissed(); if (onDismissed != null) {
} onDismissed();
}); }
});
}
Future<void> showServerError(BuildContext context, String title, String description) async {
if (title == null || title.isEmpty) {
title = I18N.of(context).serverError;
}
await showErrorDialog(
context,
title,
description,
error: I18N.of(context).serverError,
icon: FontAwesomeIcons.server
);
}
Future<void> showStatusCodeError(BuildContext context, int status, {int expected = 200}) async {
await showServerError(
context,
"Invalid Response Code",
"Server responded with status code ${status}"
);
}
Future<void> showTimeoutError(BuildContext context) async {
await showServerError(context, I18N.of(context).timeout, I18N.of(context).noResponse);
} }
void showProgressDialog(BuildContext context, String title, String description) { void showProgressDialog(BuildContext context, String title, String description) {

View File

@ -3,6 +3,7 @@ import 'package:InvenTree/barcode.dart';
import 'package:InvenTree/widget/company_list.dart'; import 'package:InvenTree/widget/company_list.dart';
import 'package:InvenTree/widget/search.dart'; import 'package:InvenTree/widget/search.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:InvenTree/api.dart'; import 'package:InvenTree/api.dart';
@ -101,65 +102,65 @@ class InvenTreeDrawer extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Drawer( return Drawer(
child: new ListView( child: new ListView(
children: <Widget>[ children: ListTile.divideTiles(
new ListTile( context: context,
leading: new Image.asset( tiles: <Widget>[
"assets/image/icon.png", new ListTile(
fit: BoxFit.scaleDown, leading: new Image.asset(
width: 40, "assets/image/icon.png",
fit: BoxFit.scaleDown,
width: 40,
),
title: new Text(I18N.of(context).appTitle),
onTap: _home,
), ),
title: new Text("InvenTree"), /*
onTap: _home, // TODO - Add search functionality!
), new ListTile(
new Divider(), title: new Text("Search"),
/* leading: new FaIcon(FontAwesomeIcons.search),
// TODO - Add search functionality! onTap: _search,
new ListTile( ),
title: new Text("Search"), */
leading: new FaIcon(FontAwesomeIcons.search), new ListTile(
onTap: _search, title: new Text(I18N.of(context).scanBarcode),
), onTap: _scan,
*/ leading: new FaIcon(FontAwesomeIcons.barcode),
new ListTile( ),
title: new Text("Scan Barcode"), new ListTile(
onTap: _scan, title: new Text(I18N.of(context).parts),
leading: new FaIcon(FontAwesomeIcons.barcode), leading: new Icon(Icons.category),
), onTap: _showParts,
new Divider(), ),
new ListTile( new ListTile(
title: new Text("Parts"), title: new Text(I18N.of(context).stock),
leading: new Icon(Icons.category), leading: new FaIcon(FontAwesomeIcons.boxes),
onTap: _showParts, onTap: _showStock,
), ),
new ListTile( /*
title: new Text("Stock"), new ListTile(
leading: new FaIcon(FontAwesomeIcons.boxes), title: new Text("Suppliers"),
onTap: _showStock, leading: new FaIcon(FontAwesomeIcons.building),
), onTap: _showSuppliers,
/* ),
new ListTile( new ListTile(
title: new Text("Suppliers"), title: Text("Manufacturers"),
leading: new FaIcon(FontAwesomeIcons.building), leading: new FaIcon(FontAwesomeIcons.industry),
onTap: _showSuppliers, onTap: _showManufacturers,
), ),
new ListTile( new ListTile(
title: Text("Manufacturers"), title: new Text("Customers"),
leading: new FaIcon(FontAwesomeIcons.industry), leading: new FaIcon(FontAwesomeIcons.users),
onTap: _showManufacturers, onTap: _showCustomers,
), ),
new ListTile( */
title: new Text("Customers"), new ListTile(
leading: new FaIcon(FontAwesomeIcons.users), title: new Text(I18N.of(context).settings),
onTap: _showCustomers, leading: new Icon(Icons.settings),
), onTap: _settings,
*/ ),
new Divider(), ]
new ListTile( ).toList(),
title: new Text("Settings"),
leading: new Icon(Icons.settings),
onTap: _settings,
),
]
) )
); );
} }

View File

@ -1,18 +1,21 @@
import 'package:InvenTree/user_profile.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:InvenTree/barcode.dart'; import 'package:InvenTree/barcode.dart';
import 'package:InvenTree/api.dart'; import 'package:InvenTree/api.dart';
import 'package:InvenTree/settings/login.dart';
import 'package:InvenTree/widget/category_display.dart'; import 'package:InvenTree/widget/category_display.dart';
import 'package:InvenTree/widget/company_list.dart'; import 'package:InvenTree/widget/company_list.dart';
import 'package:InvenTree/widget/location_display.dart'; import 'package:InvenTree/widget/location_display.dart';
import 'package:InvenTree/widget/search.dart'; import 'package:InvenTree/widget/search.dart';
import 'package:InvenTree/widget/spinner.dart';
import 'package:InvenTree/widget/drawer.dart'; import 'package:InvenTree/widget/drawer.dart';
class InvenTreeHomePage extends StatefulWidget { class InvenTreeHomePage extends StatefulWidget {
@ -24,70 +27,18 @@ class InvenTreeHomePage extends StatefulWidget {
class _InvenTreeHomePageState extends State<InvenTreeHomePage> { class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
final GlobalKey<_InvenTreeHomePageState> _homeKey = GlobalKey<_InvenTreeHomePageState>();
_InvenTreeHomePageState() : super() { _InvenTreeHomePageState() : super() {
// Initially load the profile and attempt server connection
_loadProfile();
} }
String _serverAddress = ""; // Selected user profile
UserProfile _profile;
String _serverStatus = "Connecting to server"; BuildContext _context;
String _serverMessage = "";
bool _serverConnection = false;
FaIcon _serverIcon = new FaIcon(FontAwesomeIcons.spinner);
Color _serverStatusColor = Color.fromARGB(255, 50, 50, 250);
void onConnectSuccess(String msg) {
_serverConnection = true;
_serverMessage = msg;
_serverStatus = "Connected to $_serverAddress";
_serverStatusColor = Color.fromARGB(255, 50, 250, 50);
_serverIcon = new FaIcon(FontAwesomeIcons.checkCircle, color: _serverStatusColor);
setState(() {});
}
void onConnectFailure(String msg) {
_serverConnection = false;
_serverMessage = msg;
_serverStatus = "Could not connect to $_serverAddress";
_serverStatusColor = Color.fromARGB(255, 250, 50, 50);
_serverIcon = new FaIcon(FontAwesomeIcons.timesCircle, color: _serverStatusColor);
setState(() {});
}
/*
* Test the server connection
*/
void _checkServerConnection(BuildContext context) async {
var prefs = await SharedPreferences.getInstance();
_serverAddress = prefs.getString("server");
// Reset the connection status variables
_serverStatus = "Connecting to server";
_serverMessage = "";
_serverConnection = false;
_serverIcon = new FaIcon(FontAwesomeIcons.spinner);
_serverStatusColor = Color.fromARGB(255, 50, 50, 250);
InvenTreeAPI().connect(context).then((bool result) {
if (result) {
onConnectSuccess("");
} else {
onConnectFailure("Could not connect to server");
}
});
// Update widget state
setState(() {});
}
void _search() { void _search() {
if (!InvenTreeAPI().checkConnection(context)) return; if (!InvenTreeAPI().checkConnection(context)) return;
@ -96,19 +47,19 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
} }
void _scan() { void _scan(BuildContext context) {
if (!InvenTreeAPI().checkConnection(context)) return; if (!InvenTreeAPI().checkConnection(context)) return;
scanQrCode(context); scanQrCode(context);
} }
void _parts() { void _parts(BuildContext context) {
if (!InvenTreeAPI().checkConnection(context)) return; if (!InvenTreeAPI().checkConnection(context)) return;
Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null))); Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null)));
} }
void _stock() { void _stock(BuildContext context) {
if (!InvenTreeAPI().checkConnection(context)) return; if (!InvenTreeAPI().checkConnection(context)) return;
Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(null))); Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(null)));
@ -132,6 +83,15 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
Navigator.push(context, MaterialPageRoute(builder: (context) => CustomerListWidget())); Navigator.push(context, MaterialPageRoute(builder: (context) => CustomerListWidget()));
} }
void _selectProfile() {
Navigator.push(
context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget())
).then((context) {
// Once we return
_loadProfile();
});
}
void _unsupported() { void _unsupported() {
showDialog( showDialog(
context: context, context: context,
@ -147,8 +107,92 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
); );
} }
void _loadProfile() async {
_profile = await UserProfileDBManager().getSelectedProfile();
// A valid profile was loaded!
if (_profile != null) {
if (!InvenTreeAPI().isConnected() && !InvenTreeAPI().isConnecting()) {
// Attempt server connection
InvenTreeAPI().connectToServer(_homeKey.currentContext).then((result) {
setState(() {});
});
}
}
setState(() {});
}
ListTile _serverTile() {
// No profile selected
// Tap to select / create a profile
if (_profile == null) {
return ListTile(
title: Text("No Profile Selected"),
subtitle: Text("Tap to create or select a profile"),
leading: FaIcon(FontAwesomeIcons.server),
trailing: FaIcon(
FontAwesomeIcons.user,
color: Color.fromRGBO(250, 50, 50, 1),
),
onTap: () {
_selectProfile();
},
);
}
// Profile is selected ...
if (InvenTreeAPI().isConnecting()) {
return ListTile(
title: Text("Connecting to server..."),
subtitle: Text("${InvenTreeAPI().baseUrl}"),
leading: FaIcon(FontAwesomeIcons.server),
trailing: Spinner(
icon: FontAwesomeIcons.spinner,
color: Color.fromRGBO(50, 50, 250, 1),
),
onTap: () {
_selectProfile();
}
);
} else if (InvenTreeAPI().isConnected()) {
return ListTile(
title: Text("Connected to server"),
subtitle: Text("${InvenTreeAPI().baseUrl}"),
leading: FaIcon(FontAwesomeIcons.server),
trailing: FaIcon(
FontAwesomeIcons.checkCircle,
color: Color.fromRGBO(50, 250, 50, 1)
),
onTap: () {
_selectProfile();
},
);
} else {
return ListTile(
title: Text("Could not connect to server"),
subtitle: Text("${_profile.server}"),
leading: FaIcon(FontAwesomeIcons.server),
trailing: FaIcon(
FontAwesomeIcons.timesCircle,
color: Color.fromRGBO(250, 50, 50, 1),
),
onTap: () {
_selectProfile();
},
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_context = context;
// This method is rerun every time setState is called, for instance as done // This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above. // by the _incrementCounter method above.
// //
@ -156,6 +200,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
// fast, so that you can just rebuild anything that needs updating rather // fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets. // than having to individually change instances of widgets.
return Scaffold( return Scaffold(
key: _homeKey,
appBar: AppBar( appBar: AppBar(
title: Text(I18N.of(context).appTitle), title: Text(I18N.of(context).appTitle),
actions: <Widget>[ actions: <Widget>[
@ -197,7 +242,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
IconButton( IconButton(
icon: new FaIcon(FontAwesomeIcons.barcode), icon: new FaIcon(FontAwesomeIcons.barcode),
tooltip: I18N.of(context).scanBarcode, tooltip: I18N.of(context).scanBarcode,
onPressed: _scan, onPressed: () { _scan(context); },
), ),
Text(I18N.of(context).scanBarcode), Text(I18N.of(context).scanBarcode),
], ],
@ -213,7 +258,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
IconButton( IconButton(
icon: new FaIcon(FontAwesomeIcons.shapes), icon: new FaIcon(FontAwesomeIcons.shapes),
tooltip: I18N.of(context).parts, tooltip: I18N.of(context).parts,
onPressed: _parts, onPressed: () { _parts(context); },
), ),
Text(I18N.of(context).parts), Text(I18N.of(context).parts),
], ],
@ -223,7 +268,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
IconButton( IconButton(
icon: new FaIcon(FontAwesomeIcons.boxes), icon: new FaIcon(FontAwesomeIcons.boxes),
tooltip: I18N.of(context).stock, tooltip: I18N.of(context).stock,
onPressed: _stock, onPressed: () { _stock(context); },
), ),
Text(I18N.of(context).stock), Text(I18N.of(context).stock),
], ],
@ -306,25 +351,13 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
), ),
Spacer(), Spacer(),
*/ */
Spacer(),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: ListTile( child: _serverTile(),
title: Text("$_serverStatus",
style: TextStyle(color: _serverStatusColor),
),
subtitle: Text("$_serverMessage",
style: TextStyle(color: _serverStatusColor),
),
leading: _serverIcon,
onTap: () {
if (!_serverConnection) {
_checkServerConnection(context);
}
},
),
), ),
], ],
), ),

View File

@ -6,7 +6,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:InvenTree/widget/refreshable_state.dart'; import 'package:InvenTree/widget/refreshable_state.dart';
class LocationDisplayWidget extends StatefulWidget { class LocationDisplayWidget extends StatefulWidget {
@ -97,8 +97,8 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
if (location == null) { if (location == null) {
return Card( return Card(
child: ListTile( child: ListTile(
title: Text("Stock Locations"), title: Text(I18N.of(context).stockLocations),
subtitle: Text("Top level stock location") subtitle: Text(I18N.of(context).stockTopLevel),
) )
); );
} else { } else {
@ -135,14 +135,14 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
return BottomNavigationBar( return BottomNavigationBar(
currentIndex: tabIndex, currentIndex: tabIndex,
onTap: onTabSelectionChanged, onTap: onTabSelectionChanged,
items: const <BottomNavigationBarItem> [ items: <BottomNavigationBarItem> [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.boxes), icon: FaIcon(FontAwesomeIcons.boxes),
title: Text("Stock"), title: Text(I18N.of(context).stock),
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.wrench), icon: FaIcon(FontAwesomeIcons.wrench),
title: Text("Actions"), title: Text(I18N.of(context).actions),
) )
] ]
); );
@ -156,7 +156,10 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
); );
case 1: case 1:
return ListView( return ListView(
children: actionTiles(), children: ListTile.divideTiles(
context: context,
tiles: actionTiles()
).toList()
); );
default: default:
return null; return null;
@ -298,10 +301,11 @@ class SublocationList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.builder( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: ClampingScrollPhysics(), physics: ClampingScrollPhysics(),
itemBuilder: _build, itemBuilder: _build,
separatorBuilder: (_, __) => const Divider(height: 3),
itemCount: _locations.length itemCount: _locations.length
); );
} }
@ -342,9 +346,10 @@ class StockList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.builder( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: ClampingScrollPhysics(), physics: ClampingScrollPhysics(),
separatorBuilder: (_, __) => const Divider(height: 3),
itemBuilder: _build, itemCount: _items.length); itemBuilder: _build, itemCount: _items.length);
} }
} }

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:InvenTree/inventree/part.dart'; import 'package:InvenTree/inventree/part.dart';
import 'package:InvenTree/widget/full_screen_image.dart'; import 'package:InvenTree/widget/full_screen_image.dart';
@ -82,6 +83,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
void _showStock(BuildContext context) async { void _showStock(BuildContext context) async {
await part.getStockItems(context); await part.getStockItems(context);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => PartStockDetailWidget(part)) MaterialPageRoute(builder: (context) => PartStockDetailWidget(part))
@ -213,9 +215,9 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
// Stock information // Stock information
tiles.add( tiles.add(
ListTile( ListTile(
title: Text("Stock"), title: Text(I18N.of(context).stock),
leading: FaIcon(FontAwesomeIcons.boxes), leading: FaIcon(FontAwesomeIcons.boxes),
trailing: Text("${part.inStock}"), trailing: Text("${part.inStockString}"),
onTap: () { onTap: () {
_showStock(context); _showStock(context);
}, },
@ -229,7 +231,9 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
title: Text("On Order"), title: Text("On Order"),
leading: FaIcon(FontAwesomeIcons.shoppingCart), leading: FaIcon(FontAwesomeIcons.shoppingCart),
trailing: Text("${part.onOrder}"), trailing: Text("${part.onOrder}"),
onTap: null, onTap: () {
// TODO: Click through to show items on order
},
) )
); );
} }
@ -325,7 +329,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text("Create Stock Item"), title: Text(I18N.of(context).stockItemCreate),
leading: FaIcon(FontAwesomeIcons.box), leading: FaIcon(FontAwesomeIcons.box),
onTap: null, onTap: null,
) )
@ -349,13 +353,19 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
case 0: case 0:
return Center( return Center(
child: ListView( child: ListView(
children: partTiles(), children: ListTile.divideTiles(
context: context,
tiles: partTiles()
).toList()
), ),
); );
case 1: case 1:
return Center( return Center(
child: ListView( child: ListView(
children: actionTiles(), children: ListTile.divideTiles(
context: context,
tiles: actionTiles()
).toList()
) )
); );
default: default:
@ -368,14 +378,14 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
return BottomNavigationBar( return BottomNavigationBar(
currentIndex: tabIndex, currentIndex: tabIndex,
onTap: onTabSelectionChanged, onTap: onTabSelectionChanged,
items: const <BottomNavigationBarItem> [ items: <BottomNavigationBarItem> [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.infoCircle), icon: FaIcon(FontAwesomeIcons.infoCircle),
title: Text("Details"), title: Text(I18N.of(context).details),
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.wrench), icon: FaIcon(FontAwesomeIcons.wrench),
title: Text("Actions"), title: Text(I18N.of(context).actions),
), ),
] ]
); );

View File

@ -38,7 +38,6 @@ class _PartStockDisplayState extends RefreshableState<PartStockDetailWidget> {
@override @override
Future<void> onBuild(BuildContext context) async { Future<void> onBuild(BuildContext context) async {
refresh(); refresh();
print("onBuild");
} }
@override @override
@ -100,10 +99,11 @@ class PartStockList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.builder( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: ClampingScrollPhysics(), physics: ClampingScrollPhysics(),
itemBuilder: _build, itemBuilder: _build,
separatorBuilder: (_, __) => const Divider(height: 3),
itemCount: _items.length itemCount: _items.length
); );
} }

54
lib/widget/spinner.dart Normal file
View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
class Spinner extends StatefulWidget {
final IconData icon;
final Duration duration;
final Color color;
const Spinner({
this.color = const Color.fromRGBO(150, 150, 150, 1),
Key key,
@required this.icon,
this.duration = const Duration(milliseconds: 1800),
}) : super(key: key);
@override
_SpinnerState createState() => _SpinnerState();
}
class _SpinnerState extends State<Spinner> with SingleTickerProviderStateMixin {
AnimationController _controller;
Widget _child;
@override
void initState() {
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: 2000),
)
..repeat();
_child = FaIcon(
widget.icon,
color: widget.color
);
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RotationTransition(
turns: _controller,
child: _child,
);
}
}

View File

@ -303,6 +303,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
title: Text("${item.partName}"), title: Text("${item.partName}"),
subtitle: Text("${item.partDescription}"), subtitle: Text("${item.partDescription}"),
leading: InvenTreeAPI().getImage(item.partImage), leading: InvenTreeAPI().getImage(item.partImage),
trailing: Text(item.serialOrQuantityDisplay()),
) )
); );
} }
@ -365,7 +366,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
if (item.isSerialized()) { if (item.isSerialized()) {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text("Serial Number"), title: Text(I18N.of(context).serialNumber),
leading: FaIcon(FontAwesomeIcons.hashtag), leading: FaIcon(FontAwesomeIcons.hashtag),
trailing: Text("${item.serialNumber}"), trailing: Text("${item.serialNumber}"),
) )
@ -381,7 +382,8 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
} }
// Supplier part? // Supplier part?
if (item.supplierPartId > 0) { // TODO: Display supplier part info page?
if (false && item.supplierPartId > 0) {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text("${item.supplierName}"), title: Text("${item.supplierName}"),
@ -410,7 +412,12 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
leading: FaIcon(FontAwesomeIcons.tasks), leading: FaIcon(FontAwesomeIcons.tasks),
trailing: Text("${item.testResultCount}"), trailing: Text("${item.testResultCount}"),
onTap: () { onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => StockItemTestResultsWidget(item))); Navigator.push(
context,
MaterialPageRoute(builder: (context) => StockItemTestResultsWidget(item))
).then((context) {
refresh();
});
} }
) )
); );
@ -421,7 +428,12 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
title: Text(I18N.of(context).history), title: Text(I18N.of(context).history),
leading: FaIcon(FontAwesomeIcons.history), leading: FaIcon(FontAwesomeIcons.history),
trailing: Text("${item.trackingItemCount}"), trailing: Text("${item.trackingItemCount}"),
onTap: null, onTap: () {
// TODO: Load tracking history
// TODO: Push tracking history page to the route
},
) )
); );
} }
@ -432,7 +444,10 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
title: Text(I18N.of(context).notes), title: Text(I18N.of(context).notes),
leading: FaIcon(FontAwesomeIcons.stickyNote), leading: FaIcon(FontAwesomeIcons.stickyNote),
trailing: Text(""), trailing: Text(""),
onTap: null, onTap: () {
// TODO: Load notes in markdown viewer widget
// TODO: Make this widget editable?
}
) )
); );
} }
@ -579,11 +594,17 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
switch (index) { switch (index) {
case 0: case 0:
return ListView( return ListView(
children: detailTiles(), children: ListTile.divideTiles(
context: context,
tiles: detailTiles()
).toList(),
); );
case 1: case 1:
return ListView( return ListView(
children: actionTiles(), children: ListTile.divideTiles(
context: context,
tiles: actionTiles()
).toList()
); );
default: default:
return null; return null;

View File

@ -250,7 +250,10 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
@override @override
Widget getBody(BuildContext context) { Widget getBody(BuildContext context) {
return ListView( return ListView(
children: resultsList(), children: ListTile.divideTiles(
context: context,
tiles: resultsList()
).toList()
); );
} }

View File

@ -42,7 +42,7 @@ packages:
name: camera name: camera
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.4+5" version: "0.7.0+2"
camera_platform_interface: camera_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -106,6 +106,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.3" version: "0.1.3"
device_info:
dependency: "direct main"
description:
name: device_info
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
device_info_platform_interface:
dependency: transitive
description:
name: device_info_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -152,7 +166,7 @@ packages:
name: flutter_keyboard_visibility name: flutter_keyboard_visibility
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.2" version: "4.0.3"
flutter_keyboard_visibility_platform_interface: flutter_keyboard_visibility_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -251,14 +265,14 @@ packages:
name: image_picker name: image_picker
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.7+21" version: "0.6.7+22"
image_picker_platform_interface: image_picker_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: image_picker_platform_interface name: image_picker_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.1" version: "1.1.6"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -300,7 +314,7 @@ packages:
name: package_info name: package_info
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.3+2" version: "0.4.3+4"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
@ -371,13 +385,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.3" version: "1.0.3"
preferences:
dependency: "direct main"
description:
name: preferences
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.1"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -392,6 +399,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.14" version: "0.0.14"
quiver:
dependency: transitive
description:
name: quiver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.5"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -399,13 +413,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.25.0" version: "0.25.0"
sembast:
dependency: "direct main"
description:
name: sembast
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.9"
sentry: sentry:
dependency: "direct main" dependency: "direct main"
description: description:
name: sentry name: sentry
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "4.0.4"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -447,7 +468,7 @@ packages:
name: shared_preferences_windows name: shared_preferences_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.1+3" version: "0.0.2+3"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -466,14 +487,14 @@ packages:
name: sqflite name: sqflite
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.2+2" version: "1.3.2+3"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.3" version: "1.0.3+1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -564,7 +585,7 @@ packages:
name: url_launcher_web name: url_launcher_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.5+1" version: "0.1.5+3"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
@ -572,13 +593,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.1+3" version: "0.0.1+3"
usage:
dependency: transitive
description:
name: usage
url: "https://pub.dartlang.org"
source: hosted
version: "3.4.2"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
@ -599,7 +613,7 @@ packages:
name: win32 name: win32
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.7.4" version: "1.7.4+1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@ -11,7 +11,7 @@ description: InvenTree stock management
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.1.0+1 version: 0.1.0+2
environment: environment:
sdk: ">=2.1.0 <3.0.0" sdk: ">=2.1.0 <3.0.0"
@ -30,18 +30,19 @@ dependencies:
shared_preferences: ^0.5.7 shared_preferences: ^0.5.7
cached_network_image: ^2.5.0 cached_network_image: ^2.5.0
preferences: ^5.2.0 # Persistent settings storage
qr_code_scanner: ^0.0.13 qr_code_scanner: ^0.0.13
package_info: ^0.4.0 # App information introspection package_info: ^0.4.0 # App information introspection
device_info: ^1.0.0 # Information about the device
font_awesome_flutter: ^8.8.1 # FontAwesome icon set font_awesome_flutter: ^8.8.1 # FontAwesome icon set
flutter_speed_dial: ^1.2.5 # FAB menu elements flutter_speed_dial: ^1.2.5 # FAB menu elements
sentry: ^3.0.1 # Error reporting sentry: ^4.0.4 # Error reporting
flutter_typeahead: ^1.8.1 # Auto-complete input field flutter_typeahead: ^1.8.1 # Auto-complete input field
image_picker: ^0.6.6 # Select or take photos image_picker: ^0.6.6 # Select or take photos
url_launcher: ^5.7.10 # Open link in system browser url_launcher: ^5.7.10 # Open link in system browser
flutter_markdown: ^0.5.2 # Rendering markdown flutter_markdown: ^0.5.2 # Rendering markdown
camera: camera:
path_provider: path_provider: ^1.6.27 # Local file storage
sembast: ^2.4.9 # NoSQL data storage
path: path:
dev_dependencies: dev_dependencies: