Add inventory actions
This commit is contained in:
parent
f202ccdd75
commit
a4cdd92f88
10 changed files with 437 additions and 127 deletions
|
@ -31,6 +31,11 @@ class RecordApi {
|
||||||
return body.map((e) => Record.fromMap(e)).toList();
|
return body.map((e) => Record.fromMap(e)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteRecord(ApiClient client, {required String recordId}) async {
|
||||||
|
final response = await client.delete("/users/${client.userId}/records/$recordId");
|
||||||
|
client.checkResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<PreprocessStatus> preprocessRecord(ApiClient client, {required Record record}) async {
|
static Future<PreprocessStatus> preprocessRecord(ApiClient client, {required Record record}) async {
|
||||||
final body = jsonEncode(record.toMap());
|
final body = jsonEncode(record.toMap());
|
||||||
final response = await client.post(
|
final response = await client.post(
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:contacts_plus_plus/apis/record_api.dart';
|
import 'package:contacts_plus_plus/apis/record_api.dart';
|
||||||
import 'package:contacts_plus_plus/clients/api_client.dart';
|
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||||
import 'package:contacts_plus_plus/models/inventory/neos_path.dart';
|
import 'package:contacts_plus_plus/models/inventory/neos_path.dart';
|
||||||
|
@ -13,11 +15,46 @@ class InventoryClient extends ChangeNotifier {
|
||||||
|
|
||||||
InventoryClient({required this.apiClient});
|
InventoryClient({required this.apiClient});
|
||||||
|
|
||||||
|
final Map<String, Record> _selectedRecords = {};
|
||||||
|
|
||||||
|
List<Record> get selectedRecords => _selectedRecords.values.toList();
|
||||||
|
|
||||||
|
bool get isAnyRecordSelected => _selectedRecords.isNotEmpty;
|
||||||
|
|
||||||
|
bool isRecordSelected(Record record) => _selectedRecords.containsKey(record.id);
|
||||||
|
|
||||||
|
int get selectedRecordCount => _selectedRecords.length;
|
||||||
|
|
||||||
|
bool get onlyFilesSelected => _selectedRecords.values
|
||||||
|
.every((element) => element.recordType != RecordType.link && element.recordType != RecordType.directory);
|
||||||
|
|
||||||
|
void clearSelectedRecords() {
|
||||||
|
_selectedRecords.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteSelectedRecords() async {
|
||||||
|
for (final recordId in _selectedRecords.keys) {
|
||||||
|
await RecordApi.deleteRecord(apiClient, recordId: recordId);
|
||||||
|
}
|
||||||
|
_selectedRecords.clear();
|
||||||
|
reloadCurrentDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleRecordSelected(Record record) {
|
||||||
|
if (_selectedRecords.containsKey(record.id)) {
|
||||||
|
_selectedRecords.remove(record.id);
|
||||||
|
} else {
|
||||||
|
_selectedRecords[record.id] = record;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Record>> _getDirectory(Record record) async {
|
Future<List<Record>> _getDirectory(Record record) async {
|
||||||
NeosDirectory? dir;
|
NeosDirectory? dir;
|
||||||
try {
|
try {
|
||||||
dir = await _currentDirectory;
|
dir = await _currentDirectory;
|
||||||
} catch(_) {}
|
} catch (_) {}
|
||||||
final List<Record> records;
|
final List<Record> records;
|
||||||
if (dir == null || record.isRoot) {
|
if (dir == null || record.isRoot) {
|
||||||
records = await RecordApi.getUserRecordsAt(
|
records = await RecordApi.getUserRecordsAt(
|
||||||
|
@ -26,14 +63,13 @@ class InventoryClient extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (record.recordType == RecordType.link) {
|
if (record.recordType == RecordType.link) {
|
||||||
final linkRecord = await RecordApi.getUserRecord(apiClient, recordId: record.linkRecordId, user: record.linkOwnerId);
|
final linkRecord =
|
||||||
records = await RecordApi.getUserRecordsAt(apiClient, path: "${linkRecord.path}\\${record.name}", user: linkRecord.ownerId);
|
await RecordApi.getUserRecord(apiClient, recordId: record.linkRecordId, user: record.linkOwnerId);
|
||||||
|
records = await RecordApi.getUserRecordsAt(apiClient,
|
||||||
|
path: "${linkRecord.path}\\${record.name}", user: linkRecord.ownerId);
|
||||||
} else {
|
} else {
|
||||||
records = await RecordApi.getUserRecordsAt(
|
records =
|
||||||
apiClient,
|
await RecordApi.getUserRecordsAt(apiClient, path: "${record.path}\\${record.name}", user: record.ownerId);
|
||||||
path: "${record.path}\\${record.name}",
|
|
||||||
user: record.ownerId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return records;
|
return records;
|
||||||
|
@ -54,6 +90,31 @@ class InventoryClient extends ChangeNotifier {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
_currentDirectory = rootFuture;
|
_currentDirectory = rootFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
void forceNotify() => notifyListeners();
|
||||||
|
|
||||||
|
Future<void> reloadCurrentDirectory() async {
|
||||||
|
final dir = await _currentDirectory;
|
||||||
|
|
||||||
|
if (dir == null) {
|
||||||
|
throw "Failed to reload: No directory loaded.";
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentDirectory = _getDirectory(dir.record).then(
|
||||||
|
(records) {
|
||||||
|
final children = records.map((record) => NeosDirectory.fromRecord(record: record, parent: dir)).toList();
|
||||||
|
final newDir = NeosDirectory(record: dir.record, children: children, parent: dir.parent);
|
||||||
|
|
||||||
|
final parentIdx = dir.parent?.children.indexOf(dir) ?? -1;
|
||||||
|
if (parentIdx != -1) {
|
||||||
|
dir.parent?.children[parentIdx] = newDir;
|
||||||
|
}
|
||||||
|
return newDir;
|
||||||
|
},
|
||||||
|
).onError((error, stackTrace) {
|
||||||
|
return dir;
|
||||||
|
});
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,6 +134,8 @@ class InventoryClient extends ChangeNotifier {
|
||||||
throw "Failed to open: Record is not a child of current directory.";
|
throw "Failed to open: Record is not a child of current directory.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object? caughtError;
|
||||||
|
|
||||||
if (childDir.isLoaded) {
|
if (childDir.isLoaded) {
|
||||||
_currentDirectory = Future.value(childDir);
|
_currentDirectory = Future.value(childDir);
|
||||||
} else {
|
} else {
|
||||||
|
@ -83,13 +146,22 @@ class InventoryClient extends ChangeNotifier {
|
||||||
return childDir;
|
return childDir;
|
||||||
},
|
},
|
||||||
).onError((error, stackTrace) {
|
).onError((error, stackTrace) {
|
||||||
|
caughtError = error;
|
||||||
return dir;
|
return dir;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _currentDirectory;
|
||||||
|
// Dirty hack to throw the error here instead of letting the FutureBuilder handle it. This means we can keep showing
|
||||||
|
// the previous directory while also being able to display the error as a snackbar.
|
||||||
|
if (caughtError != null) {
|
||||||
|
throw caughtError!;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> navigateUp({int times = 1}) async {
|
Future<void> navigateUp({int times = 1}) async {
|
||||||
|
if (times == 0) return;
|
||||||
|
|
||||||
var dir = await _currentDirectory;
|
var dir = await _currentDirectory;
|
||||||
if (dir == null) {
|
if (dir == null) {
|
||||||
throw "Failed to navigate up: No directory loaded.";
|
throw "Failed to navigate up: No directory loaded.";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class Config {
|
class Config {
|
||||||
static const String apiBaseUrl = "https://cloudx.azurewebsites.net";
|
static const String apiBaseUrl = "https://api.neos.com";
|
||||||
static const String legacyCloudUrl = "https://neoscloud.blob.core.windows.net/assets/";
|
static const String legacyCloudUrl = "https://neoscloud.blob.core.windows.net/assets/";
|
||||||
static const String blobStorageUrl = "https://cloudxstorage.blob.core.windows.net/assets/";
|
static const String blobStorageUrl = "https://cloudxstorage.blob.core.windows.net/assets/";
|
||||||
static const String videoStorageUrl = "https://cloudx-video.azureedge.net/";
|
static const String videoStorageUrl = "https://cloudx-video.azureedge.net/";
|
||||||
|
|
|
@ -8,19 +8,17 @@ import 'package:contacts_plus_plus/clients/messaging_client.dart';
|
||||||
import 'package:contacts_plus_plus/clients/session_client.dart';
|
import 'package:contacts_plus_plus/clients/session_client.dart';
|
||||||
import 'package:contacts_plus_plus/clients/settings_client.dart';
|
import 'package:contacts_plus_plus/clients/settings_client.dart';
|
||||||
import 'package:contacts_plus_plus/models/sem_ver.dart';
|
import 'package:contacts_plus_plus/models/sem_ver.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/friends/friends_list.dart';
|
|
||||||
import 'package:contacts_plus_plus/widgets/friends/friends_list_app_bar.dart';
|
import 'package:contacts_plus_plus/widgets/friends/friends_list_app_bar.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/homepage.dart';
|
import 'package:contacts_plus_plus/widgets/homepage.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/inventory/inventory_browser.dart';
|
|
||||||
import 'package:contacts_plus_plus/widgets/inventory/inventory_browser_app_bar.dart';
|
import 'package:contacts_plus_plus/widgets/inventory/inventory_browser_app_bar.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/login_screen.dart';
|
import 'package:contacts_plus_plus/widgets/login_screen.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/sessions/session_list.dart';
|
|
||||||
import 'package:contacts_plus_plus/widgets/sessions/session_list_app_bar.dart';
|
import 'package:contacts_plus_plus/widgets/sessions/session_list_app_bar.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/settings_app_bar.dart';
|
import 'package:contacts_plus_plus/widgets/settings_app_bar.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/settings_page.dart';
|
|
||||||
import 'package:contacts_plus_plus/widgets/update_notifier.dart';
|
import 'package:contacts_plus_plus/widgets/update_notifier.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||||
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
@ -32,20 +30,29 @@ import 'models/authentication_data.dart';
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
await FlutterDownloader.initialize(
|
||||||
|
debug: kDebugMode,
|
||||||
|
);
|
||||||
|
|
||||||
Provider.debugCheckInvalidValueType = null;
|
Provider.debugCheckInvalidValueType = null;
|
||||||
|
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
|
|
||||||
final dateFormat = DateFormat.Hms();
|
final dateFormat = DateFormat.Hms();
|
||||||
Logger.root.onRecord.listen(
|
Logger.root.onRecord.listen(
|
||||||
(event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
|
(event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
|
||||||
|
|
||||||
final settingsClient = SettingsClient();
|
final settingsClient = SettingsClient();
|
||||||
await settingsClient.loadSettings();
|
await settingsClient.loadSettings();
|
||||||
final newSettings =
|
final newSettings =
|
||||||
settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault);
|
settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault);
|
||||||
await settingsClient.changeSettings(newSettings); // Save generated machineId to disk
|
await settingsClient.changeSettings(newSettings); // Save generated machineId to disk
|
||||||
|
|
||||||
AuthenticationData cachedAuth = AuthenticationData.unauthenticated();
|
AuthenticationData cachedAuth = AuthenticationData.unauthenticated();
|
||||||
try {
|
try {
|
||||||
cachedAuth = await ApiClient.tryCachedLogin();
|
cachedAuth = await ApiClient.tryCachedLogin();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth));
|
runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,13 +67,6 @@ class ContactsPlusPlus extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
||||||
static const List<Widget> _appBars = [
|
|
||||||
FriendsListAppBar(),
|
|
||||||
SessionListAppBar(),
|
|
||||||
InventoryBrowserAppBar(),
|
|
||||||
SettingsAppBar()
|
|
||||||
];
|
|
||||||
|
|
||||||
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
||||||
late AuthenticationData _authData = widget.cachedAuthentication;
|
late AuthenticationData _authData = widget.cachedAuthentication;
|
||||||
bool _checkedForUpdate = false;
|
bool _checkedForUpdate = false;
|
||||||
|
|
|
@ -57,6 +57,11 @@ class FormatNode {
|
||||||
return spanTree;
|
return spanTree;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return text + children.join();
|
||||||
|
}
|
||||||
|
|
||||||
static FormatNode buildFromStyles(List<FormatData> styles, String text) {
|
static FormatNode buildFromStyles(List<FormatData> styles, String text) {
|
||||||
if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text);
|
if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text);
|
||||||
final root = FormatNode(text: "", format: styles.first, children: []);
|
final root = FormatNode(text: "", format: styles.first, children: []);
|
||||||
|
|
|
@ -22,7 +22,6 @@ class InventoryBrowser extends StatefulWidget {
|
||||||
|
|
||||||
class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepAliveClientMixin {
|
class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepAliveClientMixin {
|
||||||
static const Duration _refreshLimit = Duration(seconds: 60);
|
static const Duration _refreshLimit = Duration(seconds: 60);
|
||||||
final Set<String> _selectedIds = {};
|
|
||||||
Timer? _refreshLimiter;
|
Timer? _refreshLimiter;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -57,7 +56,7 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
if (_refreshLimiter?.isActive ?? false) return;
|
if (_refreshLimiter?.isActive ?? false) return;
|
||||||
try {
|
try {
|
||||||
//TODO: Reload path
|
await iClient.reloadCurrentDirectory();
|
||||||
_refreshLimiter = Timer(_refreshLimit, () {});
|
_refreshLimiter = Timer(_refreshLimit, () {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Refresh failed: $e")));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Refresh failed: $e")));
|
||||||
|
@ -72,6 +71,7 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
|
||||||
message: snapshot.error.toString(),
|
message: snapshot.error.toString(),
|
||||||
onRetry: () {
|
onRetry: () {
|
||||||
iClient.loadInventoryRoot();
|
iClient.loadInventoryRoot();
|
||||||
|
iClient.forceNotify();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -93,33 +93,34 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
|
||||||
ListView(
|
ListView(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
children: pathSegments
|
children: pathSegments
|
||||||
.mapIndexed(
|
.mapIndexed(
|
||||||
(idx, segment) => Row(
|
(idx, segment) => Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (idx != 0) const Icon(Icons.chevron_right),
|
if (idx != 0) const Icon(Icons.chevron_right),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: idx == pathSegments.length - 1
|
foregroundColor: idx == pathSegments.length - 1
|
||||||
? Theme.of(context).colorScheme.primary
|
? Theme.of(context).colorScheme.primary
|
||||||
: Theme.of(context).colorScheme.onSurface,
|
: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
iClient.navigateUp(times: pathSegments.length - 1 - idx);
|
|
||||||
},
|
|
||||||
child: Text(segment),
|
|
||||||
),
|
),
|
||||||
|
onPressed: () {
|
||||||
|
iClient.navigateUp(times: pathSegments.length - 1 - idx);
|
||||||
|
},
|
||||||
|
child: Text(segment),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
)
|
),
|
||||||
.toList(),
|
)
|
||||||
)),
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
GridView.builder(
|
GridView.builder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
@ -127,44 +128,34 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
|
||||||
itemCount: paths.length,
|
itemCount: paths.length,
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 256,
|
maxCrossAxisExtent: 256,
|
||||||
childAspectRatio: 4,
|
childAspectRatio: 3.5,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 0,
|
||||||
mainAxisSpacing: 8),
|
mainAxisSpacing: 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final record = paths[index];
|
final record = paths[index];
|
||||||
return Padding(
|
return PathInventoryTile(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 3.0),
|
record: record,
|
||||||
child: PathInventoryTile(
|
onTap: iClient.isAnyRecordSelected
|
||||||
record: record,
|
? () {}
|
||||||
selected: _selectedIds.contains(record.id),
|
: () async {
|
||||||
onTap: _selectedIds.isEmpty
|
try {
|
||||||
? () {
|
await iClient.navigateTo(record);
|
||||||
iClient.navigateTo(record);
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Failed to open directory: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: () {
|
},
|
||||||
setState(() {
|
onLongPress: () {
|
||||||
if (_selectedIds.contains(record.id)) {
|
iClient.toggleRecordSelected(record);
|
||||||
_selectedIds.remove(record.id);
|
},
|
||||||
} else {
|
|
||||||
_selectedIds.add(record.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
|
||||||
setState(() {
|
|
||||||
if (_selectedIds.contains(record.id)) {
|
|
||||||
_selectedIds.remove(record.id);
|
|
||||||
} else {
|
|
||||||
_selectedIds.add(record.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 8,
|
height: 0,
|
||||||
),
|
),
|
||||||
GridView.builder(
|
GridView.builder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
@ -174,45 +165,33 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 256,
|
maxCrossAxisExtent: 256,
|
||||||
childAspectRatio: 1,
|
childAspectRatio: 1,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 0,
|
||||||
mainAxisSpacing: 8,
|
mainAxisSpacing: 0,
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final record = objects[index];
|
final record = objects[index];
|
||||||
return ObjectInventoryTile(
|
return ObjectInventoryTile(
|
||||||
record: record,
|
record: record,
|
||||||
selected: _selectedIds.contains(record.id),
|
selected: iClient.isRecordSelected(record),
|
||||||
onTap: _selectedIds.isEmpty
|
onTap: iClient.isAnyRecordSelected
|
||||||
? () async {
|
? () async {
|
||||||
|
iClient.toggleRecordSelected(record);
|
||||||
|
}
|
||||||
|
: () async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => PhotoView(
|
builder: (context) => PhotoView(
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: PhotoViewComputedScale.contained,
|
||||||
imageProvider: CachedNetworkImageProvider(
|
imageProvider:
|
||||||
Aux.neosDbToHttp(record.thumbnailUri)),
|
CachedNetworkImageProvider(Aux.neosDbToHttp(record.thumbnailUri)),
|
||||||
heroAttributes: PhotoViewHeroAttributes(tag: record.id),
|
heroAttributes: PhotoViewHeroAttributes(tag: record.id),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
: () async {
|
|
||||||
setState(() {
|
|
||||||
if (_selectedIds.contains(record.id)) {
|
|
||||||
_selectedIds.remove(record.id);
|
|
||||||
} else {
|
|
||||||
_selectedIds.add(record.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onLongPress: () async {
|
onLongPress: () async {
|
||||||
setState(() {
|
iClient.toggleRecordSelected(record);
|
||||||
if (_selectedIds.contains(record.id)) {
|
|
||||||
_selectedIds.remove(record.id);
|
|
||||||
} else {
|
|
||||||
_selectedIds.add(record.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,20 +1,249 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'dart:isolate';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
class InventoryBrowserAppBar extends StatelessWidget {
|
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||||
|
import 'package:contacts_plus_plus/clients/inventory_client.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class InventoryBrowserAppBar extends StatefulWidget {
|
||||||
const InventoryBrowserAppBar({super.key});
|
const InventoryBrowserAppBar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InventoryBrowserAppBar> createState() => _InventoryBrowserAppBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InventoryBrowserAppBarState extends State<InventoryBrowserAppBar> {
|
||||||
|
final ReceivePort _port = ReceivePort();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
IsolateNameServer.registerPortWithName(_port.sendPort, 'downloader_send_port');
|
||||||
|
_port.listen((dynamic data) {
|
||||||
|
// Not useful yet? idk...
|
||||||
|
String id = data[0];
|
||||||
|
DownloadTaskStatus status = DownloadTaskStatus(data[1]);
|
||||||
|
int progress = data[2];
|
||||||
|
});
|
||||||
|
|
||||||
|
FlutterDownloader.registerCallback(downloadCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
IsolateNameServer.removePortNameMapping('downloader_send_port');
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
static void downloadCallback(String id, int status, int progress) {
|
||||||
|
final SendPort? send = IsolateNameServer.lookupPortByName('downloader_send_port');
|
||||||
|
send?.send([id, status, progress]);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppBar(
|
return ChangeNotifierProvider.value(
|
||||||
title: const Text("Inventory"),
|
value: Provider.of<InventoryClient>(context),
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
child: Consumer<InventoryClient>(
|
||||||
bottom: PreferredSize(
|
builder: (BuildContext context, InventoryClient iClient, Widget? child) {
|
||||||
preferredSize: const Size.fromHeight(1),
|
return AnimatedSwitcher(
|
||||||
child: Container(
|
duration: const Duration(milliseconds: 350),
|
||||||
height: 1,
|
transitionBuilder: (child, animation) => FadeTransition(
|
||||||
color: Colors.black,
|
opacity: animation,
|
||||||
),
|
child: child,
|
||||||
|
),
|
||||||
|
child: !iClient.isAnyRecordSelected
|
||||||
|
? AppBar(
|
||||||
|
key: const ValueKey("default-appbar"),
|
||||||
|
title: const Text("Inventory"),
|
||||||
|
)
|
||||||
|
: AppBar(
|
||||||
|
key: const ValueKey("selection-appbar"),
|
||||||
|
title: Text("${iClient.selectedRecordCount} Selected"),
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
iClient.clearSelectedRecords();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (iClient.onlyFilesSelected)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final selectedRecords = iClient.selectedRecords;
|
||||||
|
|
||||||
|
final assetUris = selectedRecords.map((record) => record.assetUri).toList();
|
||||||
|
final thumbUris = selectedRecords.map((record) => record.thumbnailUri).toList();
|
||||||
|
|
||||||
|
final selectedUris = await showDialog<List<String>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
title: const Text("Download what?"),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(assetUris);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.data_object),
|
||||||
|
label: Text(
|
||||||
|
"Asset${iClient.selectedRecordCount != 1 ? "s" : ""} (${assetUris.map((e) => extension(e)).toList().unique().join(", ")})",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(thumbUris);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.image),
|
||||||
|
label: Text(
|
||||||
|
"Thumbnail${iClient.selectedRecordCount != 1 ? "s" : ""} (${thumbUris.map((e) => extension(e)).toList().unique().join(", ")})",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (selectedUris == null) return;
|
||||||
|
|
||||||
|
final directory = await FilePicker.platform.getDirectoryPath(dialogTitle: "Download to...");
|
||||||
|
if (directory == null) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text("Selection aborted."),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (directory == "/") {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text("Selected directory is invalid"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var record in selectedRecords) {
|
||||||
|
final uri = selectedUris == thumbUris ? record.thumbnailUri : record.thumbnailUri;
|
||||||
|
await FlutterDownloader.enqueue(
|
||||||
|
url: Aux.neosDbToHttp(uri),
|
||||||
|
savedDir: directory,
|
||||||
|
showNotification: true,
|
||||||
|
openFileFromNotification: false,
|
||||||
|
fileName:
|
||||||
|
"${record.id.split("-")[1]}-${record.formattedName.toString()}${extension(uri)}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
iClient.clearSelectedRecords();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
width: 4,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
var loading = false;
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
return AlertDialog(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
title: Text(iClient.selectedRecordCount == 1
|
||||||
|
? "Really delete this Record?"
|
||||||
|
: "Really delete ${iClient.selectedRecordCount} Records?"),
|
||||||
|
content: const Text("This action cannot be undone!"),
|
||||||
|
actionsAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: loading
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Navigator.of(context).pop(false);
|
||||||
|
},
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (loading)
|
||||||
|
const SizedBox.square(
|
||||||
|
dimension: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
width: 4,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: loading
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
setState(() {
|
||||||
|
loading = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await iClient.deleteSelectedRecords();
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text("Failed to delete one or more records: $e"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
iClient.reloadCurrentDirectory();
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: const Text("Delete"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
width: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,25 +12,36 @@ class PathInventoryTile extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return OutlinedButton.icon(
|
return Card(
|
||||||
style: TextButton.styleFrom(
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: selected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline,
|
color: selected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline,
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
|
borderRadius: BorderRadius.circular(16),
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
),
|
),
|
||||||
onLongPress: onLongPress,
|
child: InkWell(
|
||||||
onPressed: onTap,
|
borderRadius: BorderRadius.circular(16),
|
||||||
icon: record.recordType == RecordType.directory ? const Icon(Icons.folder) : const Icon(Icons.link),
|
onTap: onTap,
|
||||||
label: FormattedText(
|
onLongPress: onLongPress,
|
||||||
record.formattedName,
|
child: Padding(
|
||||||
maxLines: 3,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Row(
|
||||||
|
children: [
|
||||||
|
record.recordType == RecordType.directory ? const Icon(Icons.folder) : const Icon(Icons.link),
|
||||||
|
const SizedBox(
|
||||||
|
width: 4,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: FormattedText(
|
||||||
|
record.formattedName,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -230,6 +230,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.0"
|
version: "3.3.0"
|
||||||
|
flutter_downloader:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_downloader
|
||||||
|
sha256: "79e05335471e23593f2e22483d2c909a03f19000293cbc7f39c8c2fd4d5d9c3d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.4"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -63,6 +63,7 @@ dependencies:
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
image_picker: ^0.8.7+5
|
image_picker: ^0.8.7+5
|
||||||
permission_handler: ^10.2.0
|
permission_handler: ^10.2.0
|
||||||
|
flutter_downloader: ^1.10.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
Loading…
Reference in a new issue