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();
|
||||
}
|
||||
|
||||
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 {
|
||||
final body = jsonEncode(record.toMap());
|
||||
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/clients/api_client.dart';
|
||||
import 'package:contacts_plus_plus/models/inventory/neos_path.dart';
|
||||
|
@ -13,11 +15,46 @@ class InventoryClient extends ChangeNotifier {
|
|||
|
||||
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 {
|
||||
NeosDirectory? dir;
|
||||
try {
|
||||
dir = await _currentDirectory;
|
||||
} catch(_) {}
|
||||
} catch (_) {}
|
||||
final List<Record> records;
|
||||
if (dir == null || record.isRoot) {
|
||||
records = await RecordApi.getUserRecordsAt(
|
||||
|
@ -26,14 +63,13 @@ class InventoryClient extends ChangeNotifier {
|
|||
);
|
||||
} else {
|
||||
if (record.recordType == RecordType.link) {
|
||||
final linkRecord = await RecordApi.getUserRecord(apiClient, recordId: record.linkRecordId, user: record.linkOwnerId);
|
||||
records = await RecordApi.getUserRecordsAt(apiClient, path: "${linkRecord.path}\\${record.name}", user: linkRecord.ownerId);
|
||||
final linkRecord =
|
||||
await RecordApi.getUserRecord(apiClient, recordId: record.linkRecordId, user: record.linkOwnerId);
|
||||
records = await RecordApi.getUserRecordsAt(apiClient,
|
||||
path: "${linkRecord.path}\\${record.name}", user: linkRecord.ownerId);
|
||||
} else {
|
||||
records = await RecordApi.getUserRecordsAt(
|
||||
apiClient,
|
||||
path: "${record.path}\\${record.name}",
|
||||
user: record.ownerId
|
||||
);
|
||||
records =
|
||||
await RecordApi.getUserRecordsAt(apiClient, path: "${record.path}\\${record.name}", user: record.ownerId);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
|
@ -54,6 +90,31 @@ class InventoryClient extends ChangeNotifier {
|
|||
},
|
||||
);
|
||||
_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();
|
||||
}
|
||||
|
||||
|
@ -73,6 +134,8 @@ class InventoryClient extends ChangeNotifier {
|
|||
throw "Failed to open: Record is not a child of current directory.";
|
||||
}
|
||||
|
||||
Object? caughtError;
|
||||
|
||||
if (childDir.isLoaded) {
|
||||
_currentDirectory = Future.value(childDir);
|
||||
} else {
|
||||
|
@ -83,13 +146,22 @@ class InventoryClient extends ChangeNotifier {
|
|||
return childDir;
|
||||
},
|
||||
).onError((error, stackTrace) {
|
||||
caughtError = error;
|
||||
return dir;
|
||||
});
|
||||
}
|
||||
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 {
|
||||
if (times == 0) return;
|
||||
|
||||
var dir = await _currentDirectory;
|
||||
if (dir == null) {
|
||||
throw "Failed to navigate up: No directory loaded.";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 blobStorageUrl = "https://cloudxstorage.blob.core.windows.net/assets/";
|
||||
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/settings_client.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/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/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/settings_app_bar.dart';
|
||||
import 'package:contacts_plus_plus/widgets/settings_page.dart';
|
||||
import 'package:contacts_plus_plus/widgets/update_notifier.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
@ -32,20 +30,29 @@ import 'models/authentication_data.dart';
|
|||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await FlutterDownloader.initialize(
|
||||
debug: kDebugMode,
|
||||
);
|
||||
|
||||
Provider.debugCheckInvalidValueType = null;
|
||||
|
||||
await Hive.initFlutter();
|
||||
|
||||
final dateFormat = DateFormat.Hms();
|
||||
Logger.root.onRecord.listen(
|
||||
(event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
|
||||
|
||||
final settingsClient = SettingsClient();
|
||||
await settingsClient.loadSettings();
|
||||
final newSettings =
|
||||
settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault);
|
||||
await settingsClient.changeSettings(newSettings); // Save generated machineId to disk
|
||||
|
||||
AuthenticationData cachedAuth = AuthenticationData.unauthenticated();
|
||||
try {
|
||||
cachedAuth = await ApiClient.tryCachedLogin();
|
||||
} catch (_) {}
|
||||
|
||||
runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth));
|
||||
}
|
||||
|
||||
|
@ -60,13 +67,6 @@ class ContactsPlusPlus extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
||||
static const List<Widget> _appBars = [
|
||||
FriendsListAppBar(),
|
||||
SessionListAppBar(),
|
||||
InventoryBrowserAppBar(),
|
||||
SettingsAppBar()
|
||||
];
|
||||
|
||||
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
||||
late AuthenticationData _authData = widget.cachedAuthentication;
|
||||
bool _checkedForUpdate = false;
|
||||
|
|
|
@ -57,6 +57,11 @@ class FormatNode {
|
|||
return spanTree;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return text + children.join();
|
||||
}
|
||||
|
||||
static FormatNode buildFromStyles(List<FormatData> styles, String text) {
|
||||
if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text);
|
||||
final root = FormatNode(text: "", format: styles.first, children: []);
|
||||
|
|
|
@ -22,7 +22,6 @@ class InventoryBrowser extends StatefulWidget {
|
|||
|
||||
class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepAliveClientMixin {
|
||||
static const Duration _refreshLimit = Duration(seconds: 60);
|
||||
final Set<String> _selectedIds = {};
|
||||
Timer? _refreshLimiter;
|
||||
|
||||
@override
|
||||
|
@ -57,7 +56,7 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
|
|||
onRefresh: () async {
|
||||
if (_refreshLimiter?.isActive ?? false) return;
|
||||
try {
|
||||
//TODO: Reload path
|
||||
await iClient.reloadCurrentDirectory();
|
||||
_refreshLimiter = Timer(_refreshLimit, () {});
|
||||
} catch (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(),
|
||||
onRetry: () {
|
||||
iClient.loadInventoryRoot();
|
||||
iClient.forceNotify();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -93,33 +93,34 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
|
|||
ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||||
child: Wrap(
|
||||
children: pathSegments
|
||||
.mapIndexed(
|
||||
(idx, segment) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (idx != 0) const Icon(Icons.chevron_right),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: idx == pathSegments.length - 1
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
onPressed: () {
|
||||
iClient.navigateUp(times: pathSegments.length - 1 - idx);
|
||||
},
|
||||
child: Text(segment),
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
child: Wrap(
|
||||
children: pathSegments
|
||||
.mapIndexed(
|
||||
(idx, segment) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (idx != 0) const Icon(Icons.chevron_right),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: idx == pathSegments.length - 1
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
onPressed: () {
|
||||
iClient.navigateUp(times: pathSegments.length - 1 - idx);
|
||||
},
|
||||
child: Text(segment),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
|
@ -127,44 +128,34 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
|
|||
itemCount: paths.length,
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 256,
|
||||
childAspectRatio: 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8),
|
||||
childAspectRatio: 3.5,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0),
|
||||
itemBuilder: (context, index) {
|
||||
final record = paths[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3.0),
|
||||
child: PathInventoryTile(
|
||||
record: record,
|
||||
selected: _selectedIds.contains(record.id),
|
||||
onTap: _selectedIds.isEmpty
|
||||
? () {
|
||||
iClient.navigateTo(record);
|
||||
return PathInventoryTile(
|
||||
record: record,
|
||||
onTap: iClient.isAnyRecordSelected
|
||||
? () {}
|
||||
: () async {
|
||||
try {
|
||||
await iClient.navigateTo(record);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Failed to open directory: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
: () {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(record.id)) {
|
||||
_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);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
onLongPress: () {
|
||||
iClient.toggleRecordSelected(record);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
height: 0,
|
||||
),
|
||||
GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
|
@ -174,45 +165,33 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
|
|||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 256,
|
||||
childAspectRatio: 1,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final record = objects[index];
|
||||
return ObjectInventoryTile(
|
||||
record: record,
|
||||
selected: _selectedIds.contains(record.id),
|
||||
onTap: _selectedIds.isEmpty
|
||||
selected: iClient.isRecordSelected(record),
|
||||
onTap: iClient.isAnyRecordSelected
|
||||
? () async {
|
||||
iClient.toggleRecordSelected(record);
|
||||
}
|
||||
: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PhotoView(
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
imageProvider: CachedNetworkImageProvider(
|
||||
Aux.neosDbToHttp(record.thumbnailUri)),
|
||||
imageProvider:
|
||||
CachedNetworkImageProvider(Aux.neosDbToHttp(record.thumbnailUri)),
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: record.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: () async {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(record.id)) {
|
||||
_selectedIds.remove(record.id);
|
||||
} else {
|
||||
_selectedIds.add(record.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
onLongPress: () async {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(record.id)) {
|
||||
_selectedIds.remove(record.id);
|
||||
} else {
|
||||
_selectedIds.add(record.id);
|
||||
}
|
||||
});
|
||||
iClient.toggleRecordSelected(record);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,19 +1,248 @@
|
|||
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});
|
||||
|
||||
@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
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
title: const Text("Inventory"),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Colors.black,
|
||||
),
|
||||
return ChangeNotifierProvider.value(
|
||||
value: Provider.of<InventoryClient>(context),
|
||||
child: Consumer<InventoryClient>(
|
||||
builder: (BuildContext context, InventoryClient iClient, Widget? child) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
color: selected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline,
|
||||
width: 1,
|
||||
),
|
||||
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
alignment: Alignment.centerLeft,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
onLongPress: onLongPress,
|
||||
onPressed: onTap,
|
||||
icon: record.recordType == RecordType.directory ? const Icon(Icons.folder) : const Icon(Icons.link),
|
||||
label: FormattedText(
|
||||
record.formattedName,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
|
||||
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"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
|
@ -63,6 +63,7 @@ dependencies:
|
|||
crypto: ^3.0.3
|
||||
image_picker: ^0.8.7+5
|
||||
permission_handler: ^10.2.0
|
||||
flutter_downloader: ^1.10.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Reference in a new issue