From 18ce8cf2cbbb3bb9045b943f4ef99205c42c7fc6 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Fri, 12 May 2023 16:56:38 +0200 Subject: [PATCH] Add initial UI for inventory browser --- lib/apis/asset_api.dart | 9 +- lib/clients/messaging_client.dart | 29 ++- lib/models/asset/record.dart | 30 ++- lib/models/friend.dart | 2 +- lib/models/inventory/neos_path.dart | 15 ++ lib/models/message.dart | 2 +- lib/widgets/home.dart | 3 +- lib/widgets/inventory/inventory_browser.dart | 231 +++++++++++++++++++ 8 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 lib/models/inventory/neos_path.dart create mode 100644 lib/widgets/inventory/inventory_browser.dart diff --git a/lib/apis/asset_api.dart b/lib/apis/asset_api.dart index 31c6efd..1867913 100644 --- a/lib/apis/asset_api.dart +++ b/lib/apis/asset_api.dart @@ -13,6 +13,13 @@ import 'package:contacts_plus_plus/models/asset/record.dart'; import 'package:path/path.dart'; class AssetApi { + static Future> getRecordsAt(ApiClient client, {required String path}) async { + final response = await client.get("/users/${client.userId}/records?path=$path"); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body) as List; + return body.map((e) => Record.fromMap(e)).toList(); + } + static Future preprocessRecord(ApiClient client, {required Record record}) async { final response = await client.post( "/users/${record.ownerId}/records/${record.id}/preprocess", body: jsonEncode(record.toMap())); @@ -69,7 +76,7 @@ class AssetApi { "message_item", "message_id:${Message.generateId()}" ], - recordType: "texture", + recordType: RecordType.texture, thumbnailUri: assetUri, isPublic: false, isForPatreons: false, diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 70673b1..ca64aa7 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -88,18 +88,7 @@ class MessagingClient extends ChangeNotifier { MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient, required SettingsClient settingsClient}) : _apiClient = apiClient, _notificationClient = notificationClient, _settingsClient = settingsClient { - initBox().whenComplete(() async { - try { - await _restoreFriendsList(); - try { - await refreshFriendsList(); - } catch (_) { - notifyListeners(); - } - } catch (e) { - refreshFriendsListWithErrorHandler(); - } - }); + initFriends(); startWebsocket(); _notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async { // We should probably let the MessagingClient handle the entire state of USerStatus instead of mirroring like this @@ -112,6 +101,22 @@ class MessagingClient extends ChangeNotifier { } } + Future initFriends() async { + try { + await initBox(); + await _restoreFriendsList(); + try { + await refreshFriendsList(); + } catch (e, s) { + FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); + notifyListeners(); + } + } catch (e,s) { + FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); + refreshFriendsListWithErrorHandler(); + } + } + Future onSettingsChanged(Settings oldSettings, Settings newSettings) async { if (oldSettings.notificationsDenied.valueOrDefault != newSettings.notificationsDenied.valueOrDefault) { if (newSettings.notificationsDenied.valueOrDefault) { diff --git a/lib/models/asset/record.dart b/lib/models/asset/record.dart index 5e9a0a2..79ea8fd 100644 --- a/lib/models/asset/record.dart +++ b/lib/models/asset/record.dart @@ -1,6 +1,21 @@ import 'package:contacts_plus_plus/models/asset/neos_db_asset.dart'; +import 'package:contacts_plus_plus/string_formatter.dart'; +import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; +enum RecordType { + unknown, + link, + object, + directory, + texture, + audio; + + factory RecordType.fromName(String? name) { + return RecordType.values.firstWhere((element) => element.name.toLowerCase() == name?.toLowerCase().trim(), orElse: () => RecordType.unknown); + } +} + class Record { final String id; final String ownerId; @@ -8,9 +23,9 @@ class Record { final int globalVersion; final int localVersion; final String name; + final TextSpan? formattedName; final String? description; final List? tags; - final String recordType; final String? thumbnailUri; final bool isPublic; final bool isForPatreons; @@ -21,9 +36,11 @@ class Record { final String lastModifyingUserId; final String lastModifyingMachineId; final DateTime? creationTime; + final RecordType recordType; const Record({ required this.id, + this.formattedName, required this.ownerId, this.assetUri, this.globalVersion=0, @@ -52,9 +69,10 @@ class Record { globalVersion: map["globalVersion"] ?? 0, localVersion: map["localVersion"] ?? 0, name: map["name"] ?? "", + formattedName: StringFormatter.tryFormat(map["name"]), description: map["description"], - tags: (map["tags"] as List).map((e) => e.toString()).toList(), - recordType: map["recordType"] ?? "", + tags: (map["tags"] as List? ?? []).map((e) => e.toString()).toList(), + recordType: RecordType.fromName(map["recordType"]), thumbnailUri: map["thumbnailUri"], isPublic: map["isPublic"] ?? false, isForPatreons: map["isForPatreons"] ?? false, @@ -75,9 +93,10 @@ class Record { int? globalVersion, int? localVersion, String? name, + TextSpan? formattedName, String? description, List? tags, - String? recordType, + RecordType? recordType, String? thumbnailUri, bool? isPublic, bool? isForPatreons, @@ -96,6 +115,7 @@ class Record { globalVersion: globalVersion ?? this.globalVersion, localVersion: localVersion ?? this.localVersion, name: name ?? this.name, + formattedName: formattedName ?? this.formattedName, description: description ?? this.description, tags: tags ?? this.tags, recordType: recordType ?? this.recordType, @@ -122,7 +142,7 @@ class Record { "name": name, "description": description, "tags": tags, - "recordType": recordType, + "recordType": recordType.name, "thumbnailUri": thumbnailUri, "isPublic": isPublic, "isForPatreons": isForPatreons, diff --git a/lib/models/friend.dart b/lib/models/friend.dart index adceacb..fb88906 100644 --- a/lib/models/friend.dart +++ b/lib/models/friend.dart @@ -2,7 +2,7 @@ import 'package:contacts_plus_plus/models/session.dart'; import 'package:contacts_plus_plus/models/user_profile.dart'; import 'package:flutter/material.dart'; -class Friend extends Comparable { +class Friend implements Comparable { final String id; final String username; final String ownerId; diff --git a/lib/models/inventory/neos_path.dart b/lib/models/inventory/neos_path.dart new file mode 100644 index 0000000..b548568 --- /dev/null +++ b/lib/models/inventory/neos_path.dart @@ -0,0 +1,15 @@ +import 'package:contacts_plus_plus/models/asset/record.dart'; + +class NeosPath { + final String name; + final NeosPath? parent; + final List children; + final Record? record; + + const NeosPath({required this.name, required this.parent, required this.children, required this.record}); + + String get absolute { + if (parent == null) return name; + return "${parent!.absolute}\\$name"; + } +} \ No newline at end of file diff --git a/lib/models/message.dart b/lib/models/message.dart index 10f5314..fbc8c58 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -37,7 +37,7 @@ enum MessageState { read, } -class Message extends Comparable { +class Message implements Comparable { final String id; final String recipientId; final String senderId; diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index bf6dba1..a3b9542 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -2,6 +2,7 @@ import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/widgets/friends/friends_list.dart'; +import 'package:contacts_plus_plus/widgets/inventory/inventory_browser.dart'; import 'package:contacts_plus_plus/widgets/sessions/sessions_list.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -59,7 +60,7 @@ class _HomeState extends State with AutomaticKeepAliveClientMixin { physics: const NeverScrollableScrollPhysics(), controller: _pageController, children: [ - const Center(child: Text("Not implemented yet"),), + const InventoryBrowser(), ChangeNotifierProvider .value( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. value: _mClient, diff --git a/lib/widgets/inventory/inventory_browser.dart b/lib/widgets/inventory/inventory_browser.dart new file mode 100644 index 0000000..7a1a7a5 --- /dev/null +++ b/lib/widgets/inventory/inventory_browser.dart @@ -0,0 +1,231 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:contacts_plus_plus/apis/asset_api.dart'; +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/client_holder.dart'; +import 'package:contacts_plus_plus/models/asset/record.dart'; +import 'package:contacts_plus_plus/models/inventory/neos_path.dart'; +import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class InventoryBrowser extends StatefulWidget { + const InventoryBrowser({super.key}); + + @override + State createState() => _InventoryBrowserState(); +} + +class _InventoryBrowserState extends State with AutomaticKeepAliveClientMixin { + static const Duration _refreshLimit = Duration(seconds: 60); + Timer? _refreshLimiter; + Future>? _inventoryFuture; + final NeosPath _inventoryRoot = const NeosPath(name: "Inventory", parent: null, children: [], record: null); + late NeosPath _currentPath = _inventoryRoot; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _inventoryFuture = _currentPathFuture(); + } + + Future> _currentPathFuture() => AssetApi.getRecordsAt( + ClientHolder.of(context).apiClient, + path: _currentPath.absolute, + ); + + @override + Widget build(BuildContext context) { + super.build(context); + return RefreshIndicator( + onRefresh: () async { + if (_refreshLimiter?.isActive ?? false) return; + try { + final records = await _currentPathFuture(); + setState(() { + _inventoryFuture = Future.value(records); + }); + _refreshLimiter = Timer(_refreshLimit, () {}); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Refresh failed: $e"))); + } + }, + child: FutureBuilder( + future: _inventoryFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final records = snapshot.data as List; + records.sort((a, b) => a.name.compareTo(b.name)); + final paths = records.where((element) => element.recordType == RecordType.link + || element.recordType == RecordType.directory).toList(); + final objects = records.where((element) => + element.recordType != RecordType.link && element.recordType != RecordType.directory).toList(); + return ListView( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 0, 16), + child: Text( + "${_currentPath.absolute}:", + style: Theme.of(context).textTheme.labelLarge?.copyWith(color: Theme.of(context).colorScheme.primary), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: paths.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 256, + childAspectRatio: 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8 + ), + itemBuilder: (context, index) { + final record = paths[index]; + return PathInventoryTile(record: record); + }, + ), + ), + const SizedBox(height: 8,), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: objects.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 256, + childAspectRatio: 1, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final record = objects[index]; + return ObjectInventoryTile(record: record); + }, + ), + ), + ] + ); + } else if (snapshot.hasError) { + FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); + return DefaultErrorWidget( + message: snapshot.error.toString(), + onRetry: () async { + setState(() { + _inventoryFuture = null; + }); + setState(() { + _inventoryFuture = _currentPathFuture(); + }); + }, + ); + } else { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: const [ + LinearProgressIndicator(), + Spacer(), + ], + ); + } + }, + ), + ); + } + + @override + bool get wantKeepAlive => true; +} + +class ObjectInventoryTile extends StatelessWidget { + ObjectInventoryTile({required this.record, super.key}); + + final Record record; + final DateFormat _dateFormat = DateFormat.yMd(); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16)), + foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, + padding: EdgeInsets.zero, + ), + onPressed: () { + + }, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(16)), + child: Stack( + alignment: Alignment.center, + children: [ + CachedNetworkImage( + imageUrl: Aux.neosDbToHttp(record.thumbnailUri), + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ), + Align( + alignment: Alignment.bottomCenter, + child: Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + color: Theme.of(context).colorScheme.secondaryContainer, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + record.formattedName == null + ? Text(record.name, maxLines: 2, overflow: TextOverflow.ellipsis) + : RichText(text: record.formattedName!, maxLines: 2, overflow: TextOverflow.ellipsis,), + if (record.creationTime != null) Row( + children: [ + const Icon(Icons.access_time, size: 12, color: Colors.white54,), + const SizedBox(width: 4,), + Text(_dateFormat.format(record.creationTime!), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white54),), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + + +class PathInventoryTile extends StatelessWidget { + const PathInventoryTile({required this.record, super.key}); + + final Record record; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16)), + foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, + alignment: Alignment.centerLeft + ), + onPressed: () { + + }, + icon: record.recordType == RecordType.directory ? const Icon(Icons.folder) : const Icon(Icons.link), + label: record.formattedName == null + ? Text(record.name, maxLines: 3, overflow: TextOverflow.ellipsis) + : RichText(text: record.formattedName!, maxLines: 3, overflow: TextOverflow.ellipsis), + ); + } + +} \ No newline at end of file