Add inventory actions

This commit is contained in:
Nutcake 2023-06-23 22:23:46 +02:00
parent f202ccdd75
commit a4cdd92f88
10 changed files with 437 additions and 127 deletions

View file

@ -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(

View file

@ -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.";

View file

@ -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/";

View file

@ -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;

View file

@ -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: []);

View file

@ -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);
}
});
}, },
); );
}, },

View file

@ -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,
),
],
),
);
},
), ),
); );
} }
} }

View file

@ -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,
),
),
],
),
),
), ),
); );
} }

View file

@ -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:

View file

@ -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: