Add initial UI for inventory browser

This commit is contained in:
Nutcake 2023-05-12 16:56:38 +02:00
parent a823604822
commit 18ce8cf2cb
8 changed files with 300 additions and 21 deletions

View file

@ -13,6 +13,13 @@ import 'package:contacts_plus_plus/models/asset/record.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
class AssetApi { class AssetApi {
static Future<List<Record>> 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<PreprocessStatus> preprocessRecord(ApiClient client, {required Record record}) async { static Future<PreprocessStatus> preprocessRecord(ApiClient client, {required Record record}) async {
final response = await client.post( final response = await client.post(
"/users/${record.ownerId}/records/${record.id}/preprocess", body: jsonEncode(record.toMap())); "/users/${record.ownerId}/records/${record.id}/preprocess", body: jsonEncode(record.toMap()));
@ -69,7 +76,7 @@ class AssetApi {
"message_item", "message_item",
"message_id:${Message.generateId()}" "message_id:${Message.generateId()}"
], ],
recordType: "texture", recordType: RecordType.texture,
thumbnailUri: assetUri, thumbnailUri: assetUri,
isPublic: false, isPublic: false,
isForPatreons: false, isForPatreons: false,

View file

@ -88,18 +88,7 @@ class MessagingClient extends ChangeNotifier {
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient, MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient,
required SettingsClient settingsClient}) required SettingsClient settingsClient})
: _apiClient = apiClient, _notificationClient = notificationClient, _settingsClient = settingsClient { : _apiClient = apiClient, _notificationClient = notificationClient, _settingsClient = settingsClient {
initBox().whenComplete(() async { initFriends();
try {
await _restoreFriendsList();
try {
await refreshFriendsList();
} catch (_) {
notifyListeners();
}
} catch (e) {
refreshFriendsListWithErrorHandler();
}
});
startWebsocket(); startWebsocket();
_notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async { _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 // 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<void> 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<void> onSettingsChanged(Settings oldSettings, Settings newSettings) async { Future<void> onSettingsChanged(Settings oldSettings, Settings newSettings) async {
if (oldSettings.notificationsDenied.valueOrDefault != newSettings.notificationsDenied.valueOrDefault) { if (oldSettings.notificationsDenied.valueOrDefault != newSettings.notificationsDenied.valueOrDefault) {
if (newSettings.notificationsDenied.valueOrDefault) { if (newSettings.notificationsDenied.valueOrDefault) {

View file

@ -1,6 +1,21 @@
import 'package:contacts_plus_plus/models/asset/neos_db_asset.dart'; 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'; 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 { class Record {
final String id; final String id;
final String ownerId; final String ownerId;
@ -8,9 +23,9 @@ class Record {
final int globalVersion; final int globalVersion;
final int localVersion; final int localVersion;
final String name; final String name;
final TextSpan? formattedName;
final String? description; final String? description;
final List<String>? tags; final List<String>? tags;
final String recordType;
final String? thumbnailUri; final String? thumbnailUri;
final bool isPublic; final bool isPublic;
final bool isForPatreons; final bool isForPatreons;
@ -21,9 +36,11 @@ class Record {
final String lastModifyingUserId; final String lastModifyingUserId;
final String lastModifyingMachineId; final String lastModifyingMachineId;
final DateTime? creationTime; final DateTime? creationTime;
final RecordType recordType;
const Record({ const Record({
required this.id, required this.id,
this.formattedName,
required this.ownerId, required this.ownerId,
this.assetUri, this.assetUri,
this.globalVersion=0, this.globalVersion=0,
@ -52,9 +69,10 @@ class Record {
globalVersion: map["globalVersion"] ?? 0, globalVersion: map["globalVersion"] ?? 0,
localVersion: map["localVersion"] ?? 0, localVersion: map["localVersion"] ?? 0,
name: map["name"] ?? "", name: map["name"] ?? "",
formattedName: StringFormatter.tryFormat(map["name"]),
description: map["description"], description: map["description"],
tags: (map["tags"] as List).map((e) => e.toString()).toList(), tags: (map["tags"] as List? ?? []).map((e) => e.toString()).toList(),
recordType: map["recordType"] ?? "", recordType: RecordType.fromName(map["recordType"]),
thumbnailUri: map["thumbnailUri"], thumbnailUri: map["thumbnailUri"],
isPublic: map["isPublic"] ?? false, isPublic: map["isPublic"] ?? false,
isForPatreons: map["isForPatreons"] ?? false, isForPatreons: map["isForPatreons"] ?? false,
@ -75,9 +93,10 @@ class Record {
int? globalVersion, int? globalVersion,
int? localVersion, int? localVersion,
String? name, String? name,
TextSpan? formattedName,
String? description, String? description,
List<String>? tags, List<String>? tags,
String? recordType, RecordType? recordType,
String? thumbnailUri, String? thumbnailUri,
bool? isPublic, bool? isPublic,
bool? isForPatreons, bool? isForPatreons,
@ -96,6 +115,7 @@ class Record {
globalVersion: globalVersion ?? this.globalVersion, globalVersion: globalVersion ?? this.globalVersion,
localVersion: localVersion ?? this.localVersion, localVersion: localVersion ?? this.localVersion,
name: name ?? this.name, name: name ?? this.name,
formattedName: formattedName ?? this.formattedName,
description: description ?? this.description, description: description ?? this.description,
tags: tags ?? this.tags, tags: tags ?? this.tags,
recordType: recordType ?? this.recordType, recordType: recordType ?? this.recordType,
@ -122,7 +142,7 @@ class Record {
"name": name, "name": name,
"description": description, "description": description,
"tags": tags, "tags": tags,
"recordType": recordType, "recordType": recordType.name,
"thumbnailUri": thumbnailUri, "thumbnailUri": thumbnailUri,
"isPublic": isPublic, "isPublic": isPublic,
"isForPatreons": isForPatreons, "isForPatreons": isForPatreons,

View file

@ -2,7 +2,7 @@ import 'package:contacts_plus_plus/models/session.dart';
import 'package:contacts_plus_plus/models/user_profile.dart'; import 'package:contacts_plus_plus/models/user_profile.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class Friend extends Comparable { class Friend implements Comparable {
final String id; final String id;
final String username; final String username;
final String ownerId; final String ownerId;

View file

@ -0,0 +1,15 @@
import 'package:contacts_plus_plus/models/asset/record.dart';
class NeosPath {
final String name;
final NeosPath? parent;
final List<NeosPath> 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";
}
}

View file

@ -37,7 +37,7 @@ enum MessageState {
read, read,
} }
class Message extends Comparable { class Message implements Comparable {
final String id; final String id;
final String recipientId; final String recipientId;
final String senderId; final String senderId;

View file

@ -2,6 +2,7 @@
import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/messaging_client.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/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:contacts_plus_plus/widgets/sessions/sessions_list.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -59,7 +60,7 @@ class _HomeState extends State<Home> with AutomaticKeepAliveClientMixin {
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
controller: _pageController, controller: _pageController,
children: [ children: [
const Center(child: Text("Not implemented yet"),), const InventoryBrowser(),
ChangeNotifierProvider ChangeNotifierProvider
.value( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. .value( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
value: _mClient, value: _mClient,

View file

@ -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<StatefulWidget> createState() => _InventoryBrowserState();
}
class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepAliveClientMixin {
static const Duration _refreshLimit = Duration(seconds: 60);
Timer? _refreshLimiter;
Future<List<Record>>? _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<List<Record>> _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<Record>;
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),
);
}
}