Add Inventory viewer

This commit is contained in:
Nutcake 2023-06-17 16:58:32 +02:00
parent adf96e887f
commit 83c92a4152
16 changed files with 784 additions and 137 deletions

View file

@ -17,8 +17,15 @@ import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart';
class RecordApi {
static Future<List<Record>> getRecordsAt(ApiClient client, {required String path}) async {
final response = await client.get("/users/${client.userId}/records?path=$path");
static Future<Record> getUserRecord(ApiClient client, {required String recordId, String? user}) async {
final response = await client.get("/users/${user ?? client.userId}/records/$recordId");
client.checkResponse(response);
final body = jsonDecode(response.body) as Map;
return Record.fromMap(body);
}
static Future<List<Record>> getUserRecordsAt(ApiClient client, {required String path, String? user}) async {
final response = await client.get("/users/${user ?? client.userId}/records?path=$path");
client.checkResponse(response);
final body = jsonDecode(response.body) as List;
return body.map((e) => Record.fromMap(e)).toList();

View file

@ -16,22 +16,27 @@ class ApiClient {
static const String tokenKey = "token";
static const String passwordKey = "password";
ApiClient({required AuthenticationData authenticationData, required this.onLogout}) : _authenticationData = authenticationData;
ApiClient({required AuthenticationData authenticationData, required this.onLogout})
: _authenticationData = authenticationData;
final AuthenticationData _authenticationData;
final Logger _logger = Logger("API");
// Saving the context here feels kinda cringe ngl
final Function() onLogout;
final http.Client _client = http.Client();
AuthenticationData get authenticationData => _authenticationData;
String get userId => _authenticationData.userId;
bool get isAuthenticated => _authenticationData.isAuthenticated;
static Future<AuthenticationData> tryLogin({
required String username,
required String password,
bool rememberMe=true,
bool rememberPass=true,
bool rememberMe = true,
bool rememberPass = true,
String? oneTimePad,
}) async {
final body = {
@ -44,7 +49,7 @@ class ApiClient {
buildFullUri("/UserSessions"),
headers: {
"Content-Type": "application/json",
if (oneTimePad != null) totpKey : oneTimePad,
if (oneTimePad != null) totpKey: oneTimePad,
},
body: jsonEncode(body),
);
@ -83,9 +88,8 @@ class ApiClient {
}
if (token != null) {
final response = await http.patch(buildFullUri("/userSessions"), headers: {
"Authorization": "neos $userId:$token"
});
final response =
await http.patch(buildFullUri("/userSessions"), headers: {"Authorization": "neos $userId:$token"});
if (response.statusCode < 300) {
return AuthenticationData(userId: userId, token: token, secretMachineId: machineId, isAuthenticated: true);
}
@ -135,13 +139,14 @@ class ApiClient {
static void checkResponseCode(http.Response response) {
if (response.statusCode < 300) return;
final error = "${switch (response.statusCode) {
final error =
"${response.request?.method ?? "Unknown Method"}|${response.request?.url ?? "Unknown URL"}: ${switch (response.statusCode) {
429 => "You are being rate limited.",
403 => "You are not authorized to do that.",
404 => "Resource not found.",
500 => "Internal server error.",
_ => "Unknown Error."
}} (${response.statusCode}${kDebugMode ? "|${response.body}" : ""})";
}} (${response.statusCode}${kDebugMode && response.body.isNotEmpty ? "|${response.body}" : ""})";
FlutterError.reportError(FlutterErrorDetails(exception: error));
throw error;
@ -154,7 +159,7 @@ class ApiClient {
Future<http.Response> get(String path, {Map<String, String>? headers}) async {
headers ??= {};
headers.addAll(authorizationHeader);
final response = await http.get(buildFullUri(path), headers: headers);
final response = await _client.get(buildFullUri(path), headers: headers);
_logger.info("GET $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
return response;
}
@ -163,7 +168,7 @@ class ApiClient {
headers ??= {};
headers["Content-Type"] = "application/json";
headers.addAll(authorizationHeader);
final response = await http.post(buildFullUri(path), headers: headers, body: body);
final response = await _client.post(buildFullUri(path), headers: headers, body: body);
_logger.info("PST $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
return response;
}
@ -172,7 +177,7 @@ class ApiClient {
headers ??= {};
headers["Content-Type"] = "application/json";
headers.addAll(authorizationHeader);
final response = await http.put(buildFullUri(path), headers: headers, body: body);
final response = await _client.put(buildFullUri(path), headers: headers, body: body);
_logger.info("PUT $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
return response;
}
@ -180,7 +185,7 @@ class ApiClient {
Future<http.Response> delete(String path, {Map<String, String>? headers}) async {
headers ??= {};
headers.addAll(authorizationHeader);
final response = await http.delete(buildFullUri(path), headers: headers);
final response = await _client.delete(buildFullUri(path), headers: headers);
_logger.info("DEL $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
return response;
}
@ -189,7 +194,7 @@ class ApiClient {
headers ??= {};
headers["Content-Type"] = "application/json";
headers.addAll(authorizationHeader);
final response = await http.patch(buildFullUri(path), headers: headers, body: body);
final response = await _client.patch(buildFullUri(path), headers: headers, body: body);
_logger.info("PAT $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
return response;
}

View file

@ -0,0 +1,99 @@
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';
import 'package:contacts_plus_plus/models/records/record.dart';
import 'package:flutter/material.dart';
class InventoryClient extends ChangeNotifier {
final ApiClient apiClient;
Future<NeosDirectory>? _currentDirectory;
Future<NeosDirectory>? get directoryFuture => _currentDirectory;
InventoryClient({required this.apiClient});
Future<List<Record>> _getDirectory(Record record) async {
final dir = await _currentDirectory;
final List<Record> records;
if (dir == null || record.isRoot) {
records = await RecordApi.getUserRecordsAt(
apiClient,
path: NeosDirectory.rootName,
);
} 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);
} else {
records = await RecordApi.getUserRecordsAt(
apiClient,
path: "${record.path}\\${record.name}",
user: record.ownerId
);
}
}
return records;
}
void loadInventoryRoot() {
final rootRecord = Record.inventoryRoot();
final rootFuture = _getDirectory(rootRecord).then(
(records) {
final rootDir = NeosDirectory(
record: rootRecord,
children: [],
);
rootDir.children.addAll(
records.map((e) => NeosDirectory.fromRecord(record: e, parent: rootDir)).toList(),
);
return rootDir;
},
);
_currentDirectory = rootFuture;
notifyListeners();
}
Future<void> navigateTo(Record record) async {
final dir = await _currentDirectory;
if (dir == null) {
throw "Failed to open: No directory loaded.";
}
if (record.recordType != RecordType.directory && record.recordType != RecordType.link) {
throw "Failed to open: Record is not a directory.";
}
final childDir = dir.findChildByRecord(record);
if (childDir == null) {
throw "Failed to open: Record is not a child of current directory.";
}
if (childDir.isLoaded) {
_currentDirectory = Future.value(childDir);
} else {
_currentDirectory = _getDirectory(record).then(
(records) {
childDir.children.clear();
childDir.children.addAll(records.map((record) => NeosDirectory.fromRecord(record: record, parent: childDir)));
return childDir;
},
);
}
notifyListeners();
}
Future<void> navigateUp() async {
final dir = await _currentDirectory;
if (dir == null) {
throw "Failed to navigate up: No directory loaded.";
}
if (dir.record.isRoot) {
throw "Failed navigate up: Already at root";
}
_currentDirectory = Future.value(dir.parent);
notifyListeners();
}
}

View file

@ -366,6 +366,8 @@ class MessagingClient extends ChangeNotifier {
if (message.senderId != selectedFriend?.id) {
addUnread(message);
updateFriendStatus(message.senderId);
} else {
markMessagesRead(MarkReadBatch(senderId: message.senderId, ids: [message.id], readTime: DateTime.now()));
}
notifyListeners();
break;

View file

@ -11,9 +11,7 @@ class SessionClient extends ChangeNotifier {
SessionFilterSettings _filterSettings = SessionFilterSettings.empty();
SessionClient({required this.apiClient}) {
reloadSessions();
}
SessionClient({required this.apiClient});
SessionFilterSettings get filterSettings => _filterSettings;

View file

@ -3,12 +3,16 @@ import 'dart:developer';
import 'package:contacts_plus_plus/apis/github_api.dart';
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/clients/inventory_client.dart';
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';
@ -57,23 +61,15 @@ class ContactsPlusPlus extends StatefulWidget {
class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
static const List<Widget> _appBars = [
FriendsListAppBar(
key: ValueKey("friends_list_app_bar"),
),
SessionListAppBar(
key: ValueKey("session_list_app_bar"),
),
SettingsAppBar(
key: ValueKey("settings_app_bar"),
)
FriendsListAppBar(),
SessionListAppBar(),
InventoryBrowserAppBar(),
SettingsAppBar()
];
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
final PageController _pageController = PageController();
late AuthenticationData _authData = widget.cachedAuthentication;
bool _checkedForUpdate = false;
int _selectedPage = 0;
void showUpdateDialogOnFirstBuild(BuildContext context) {
final navigator = Navigator.of(context);
@ -171,58 +167,13 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
apiClient: clientHolder.apiClient,
),
),
Provider(
create: (context) => InventoryClient(
apiClient: clientHolder.apiClient,
),
)
],
child: Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _appBars[_selectedPage],
),
),
body: PageView(
controller: _pageController,
children: const [
FriendsList(),
SessionList(),
SettingsPage(),
],
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
border: const Border(top: BorderSide(width: 1, color: Colors.black)),
color: Theme.of(context).colorScheme.background,
),
child: BottomNavigationBar(
selectedItemColor: Theme.of(context).colorScheme.primary,
currentIndex: _selectedPage,
onTap: (index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
setState(() {
_selectedPage = index;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.message),
label: "Chat",
),
BottomNavigationBarItem(
icon: Icon(Icons.public),
label: "Sessions",
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: "Settings",
),
],
),
),
),
child: const Home(),
)
: LoginScreen(
onLoginSuccessful: (AuthenticationData authData) async {

View file

@ -0,0 +1,64 @@
import 'package:collection/collection.dart';
import 'package:contacts_plus_plus/stack.dart';
import 'package:contacts_plus_plus/models/records/record.dart';
class NeosPath {
static const _root = "Inventory";
final Stack<NeosDirectory> _pathStack = Stack<NeosDirectory>();
String get absolute {
if (_pathStack.isEmpty) return _root;
var path = _pathStack.entries.join("\\");
return "$_root\\$path";
}
NeosDirectory pop() => _pathStack.pop();
void push(NeosDirectory directory) => _pathStack.push(directory);
bool get isRoot => _pathStack.isEmpty;
/*
NeosDirectory get current => _pathStack.peek ?? NeosDirectory(name: _root);
void populateCurrent(String target, Iterable<Record> records) {
var currentDir = _pathStack.peek;
if (currentDir?.name != target) return;
currentDir?.records.addAll(records);
}
*/
}
class NeosDirectory {
static const rootName = "Inventory";
final Record record;
final NeosDirectory? parent;
final List<NeosDirectory> children;
NeosDirectory({required this.record, this.parent, required this.children});
factory NeosDirectory.fromRecord({required Record record, NeosDirectory? parent}) {
return NeosDirectory(record: record, parent: parent, children: []);
}
@override
String toString() {
return record.name;
}
bool get isRoot => record.isRoot;
String get absolutePath => "${parent?.absolutePath ?? ""}/${(record.name)}";
List<String> get absolutePathSegments => (parent?.absolutePathSegments ?? []) + [record.name];
bool containsRecord(Record record) => children.where((element) => element.record.id == record.id).isNotEmpty;
List<Record> get records => children.map((e) => e.record).toList();
bool get isLoaded => children.isNotEmpty;
NeosDirectory? findChildByRecord(Record record) => children.firstWhereOrNull((element) => element.record.id == record.id);
}

View file

@ -4,6 +4,7 @@ import 'package:contacts_plus_plus/models/records/asset_digest.dart';
import 'package:contacts_plus_plus/models/records/neos_db_asset.dart';
import 'package:contacts_plus_plus/string_formatter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:uuid/uuid.dart';
enum RecordType {
@ -15,7 +16,8 @@ enum RecordType {
audio;
factory RecordType.fromName(String? name) {
return RecordType.values.firstWhere((element) => element.name.toLowerCase() == name?.toLowerCase().trim(), orElse: () => RecordType.unknown);
return RecordType.values.firstWhere((element) => element.name.toLowerCase() == name?.toLowerCase().trim(),
orElse: () => RecordType.unknown);
}
}
@ -24,7 +26,7 @@ class RecordId {
final String? ownerId;
final bool isValid;
const RecordId({required this.id, required this.ownerId, required this.isValid});
const RecordId({this.id, this.ownerId, required this.isValid});
factory RecordId.fromMap(Map? map) {
return RecordId(id: map?["id"], ownerId: map?["ownerId"], isValid: map?["isValid"] ?? false);
@ -40,6 +42,38 @@ class RecordId {
}
class Record {
static final _rootRecord = Record(
id: "0",
combinedRecordId: const RecordId(isValid: false),
isSynced: true,
fetchedOn: DateTimeX.epoch,
path: "Inventory",
ownerId: "",
assetUri: "",
name: "Inventory",
description: "",
tags: [],
recordType: RecordType.directory,
thumbnailUri: "",
isPublic: false,
isListed: false,
isForPatreons: false,
lastModificationTime: DateTimeX.epoch,
neosDBManifest: [],
lastModifyingUserId: "",
lastModifyingMachineId: "",
creationTime: DateTimeX.epoch,
manifest: [],
url: "",
isValidOwnerId: true,
isValidRecordId: true,
globalVersion: 1,
localVersion: 1,
visits: 0,
rating: 0,
randomOrder: 0,
);
final String id;
final RecordId combinedRecordId;
final String ownerId;
@ -119,12 +153,8 @@ class Record {
combinedRecordId: combinedRecordId,
assetUri: assetUri,
name: filename,
tags: ([
filename,
"message_item",
"message_id:${Message.generateId()}",
"contacts-plus-plus"
] + (extraTags ?? [])).unique(),
tags: ([filename, "message_item", "message_id:${Message.generateId()}", "contacts-plus-plus"] + (extraTags ?? []))
.unique(),
recordType: recordType,
thumbnailUri: thumbnailUri,
isPublic: false,
@ -174,16 +204,47 @@ class Record {
lastModifyingMachineId: map["lastModifyingMachineId"] ?? "",
creationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch,
isSynced: map["isSynced"] ?? false,
fetchedOn: DateTime.tryParse(map["fetchedOn"]) ?? DateTimeX.epoch,
fetchedOn: DateTime.tryParse(map["fetchedOn"] ?? "") ?? DateTimeX.epoch,
path: map["path"] ?? "",
manifest: (map["neosDBManifest"] as List? ?? []).map((e) => e.toString()).toList(),
url: map["url"] ?? "",
isValidOwnerId: map["isValidOwnerId"] ?? "",
isValidRecordId: map["isValidRecordId"] ?? "",
isValidOwnerId: map["isValidOwnerId"] == "true",
isValidRecordId: map["isValidRecordId"] == "true",
visits: map["visits"] ?? 0,
rating: map["rating"] ?? 0,
randomOrder: map["randomOrder"] ?? 0
);
randomOrder: map["randomOrder"] ?? 0);
}
factory Record.inventoryRoot() => _rootRecord;
bool get isRoot => this == _rootRecord;
String get linkRecordId {
if (!assetUri.startsWith("neosrec")) {
throw "Record is not a link.";
}
final lastSlashIdx = assetUri.lastIndexOf("/");
if (lastSlashIdx == -1) {
throw "Record has invalid assetUri";
}
return assetUri.substring(lastSlashIdx+1);
}
String get linkOwnerId {
if (!assetUri.startsWith("neosrec")) {
throw "Record is not a link.";
}
String ownerId = assetUri.replaceFirst("neosrec:///", "");
final lastSlashIdx = ownerId.lastIndexOf("/");
if (lastSlashIdx == -1) {
throw "Record has invalid assetUri";
}
return ownerId.substring(0, lastSlashIdx);
}
Record copyWith({

14
lib/stack.dart Normal file
View file

@ -0,0 +1,14 @@
class Stack<T> {
final List<T> _data = <T>[];
void push(T entry) => _data.add(entry);
T pop() => _data.removeLast();
T? get peek => _data.lastOrNull;
List<T> get entries => List.from(_data);
bool get isEmpty => _data.isEmpty;
}

View file

@ -5,7 +5,6 @@ import 'package:contacts_plus_plus/models/users/online_status.dart';
import 'package:contacts_plus_plus/models/users/user_status.dart';
import 'package:contacts_plus_plus/widgets/friends/user_search.dart';
import 'package:contacts_plus_plus/widgets/my_profile_dialog.dart';
import 'package:contacts_plus_plus/widgets/settings_page.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@ -156,13 +155,6 @@ class _FriendsListAppBarState extends State<FriendsListAppBar> with AutomaticKee
await itemDef.onTap();
},
itemBuilder: (BuildContext context) => [
MenuItemDefinition(
name: "Settings",
icon: Icons.settings,
onTap: () async {
await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage()));
},
),
MenuItemDefinition(
name: "Find Users",
icon: Icons.person_add,

91
lib/widgets/homepage.dart Normal file
View file

@ -0,0 +1,91 @@
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/inventory/inventory_browser.dart';
import 'package:contacts_plus_plus/widgets/inventory/inventory_browser_app_bar.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:flutter/material.dart';
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
static const List<Widget> _appBars = [
FriendsListAppBar(),
SessionListAppBar(),
InventoryBrowserAppBar(),
SettingsAppBar()
];
final PageController _pageController = PageController();
int _selectedPage = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _appBars[_selectedPage],
),
),
body: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
children: const [
FriendsList(),
SessionList(),
InventoryBrowser(),
SettingsPage(),
],
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
border: const Border(top: BorderSide(width: 1, color: Colors.black)),
color: Theme.of(context).colorScheme.background,
),
child: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
unselectedItemColor: Theme.of(context).colorScheme.onBackground,
selectedItemColor: Theme.of(context).colorScheme.primary,
currentIndex: _selectedPage,
onTap: (index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
setState(() {
_selectedPage = index;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.message),
label: "Chat",
),
BottomNavigationBarItem(
icon: Icon(Icons.public),
label: "Sessions",
),
BottomNavigationBarItem(
icon: Icon(Icons.inventory),
label: "Inventory",
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: "Settings",
),
],
),
),
);
}
}

View file

@ -0,0 +1,197 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/clients/inventory_client.dart';
import 'package:contacts_plus_plus/models/inventory/neos_path.dart';
import 'package:contacts_plus_plus/models/records/record.dart';
import 'package:contacts_plus_plus/widgets/default_error_widget.dart';
import 'package:contacts_plus_plus/widgets/inventory/object_inventory_tile.dart';
import 'package:contacts_plus_plus/widgets/inventory/path_inventory_tile.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.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);
final Set<String> _selectedIds = {};
Timer? _refreshLimiter;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final iClient = Provider.of<InventoryClient>(context, listen: false);
if (iClient.directoryFuture == null) {
iClient.loadInventoryRoot();
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return ChangeNotifierProvider.value(
value: Provider.of<InventoryClient>(context),
child: Consumer<InventoryClient>(
builder: (BuildContext context, InventoryClient iClient, Widget? child) {
return FutureBuilder<NeosDirectory>(
future: iClient.directoryFuture,
builder: (context, snapshot) {
final currentDir = snapshot.data;
return WillPopScope(
onWillPop: () async {
// Allow pop when at root or not loaded
if (currentDir?.isRoot ?? true) {
return true;
}
iClient.navigateUp();
return false;
},
child: RefreshIndicator(
onRefresh: () async {
if (_refreshLimiter?.isActive ?? false) return;
try {
//TODO: Reload path
_refreshLimiter = Timer(_refreshLimit, () {});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Refresh failed: $e")));
}
},
child: Builder(
builder: (context) {
if (snapshot.hasError) {
FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
return DefaultErrorWidget(
message: snapshot.error.toString(),
onRetry: () async {
iClient.loadInventoryRoot();
await iClient.directoryFuture;
},
);
}
final directory = snapshot.data;
final records = directory?.records ?? [];
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 Stack(
children: [
ListView(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: Text(
directory?.absolutePath ?? NeosDirectory.rootName,
style: Theme
.of(context)
.textTheme
.labelLarge
?.copyWith(color: Theme
.of(context)
.colorScheme
.primary),
),
),
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
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,
onPressed: () {
iClient.navigateTo(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,
selected: _selectedIds.contains(record.id),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PhotoView(
minScale: PhotoViewComputedScale.contained,
imageProvider: CachedNetworkImageProvider(
Aux.neosDbToHttp(record.thumbnailUri)),
heroAttributes: PhotoViewHeroAttributes(tag: record.id),
),
),
);
},
onLongPress: () async {
setState(() {
if (_selectedIds.contains(record.id)) {
_selectedIds.remove(record.id);
} else {
_selectedIds.add(record.id);
}
});
},
);
},
),
),
],
),
if (snapshot.connectionState == ConnectionState.waiting)
Align(
alignment: Alignment.center,
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.black38,
child: const Center(child: CircularProgressIndicator()),
),
)
],
);
},
),
),
);
}
);
}
),
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
class InventoryBrowserAppBar extends StatelessWidget {
const InventoryBrowserAppBar({super.key});
@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,
),
),
);
}
}

View file

@ -0,0 +1,107 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/models/records/record.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../formatted_text.dart';
class ObjectInventoryTile extends StatelessWidget {
ObjectInventoryTile({required this.record, this.onTap, this.onLongPress, this.selected=false, super.key});
final bool selected;
final Record record;
final void Function()? onTap;
final void Function()? onLongPress;
final DateFormat _dateFormat = DateFormat.yMd();
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(
color: selected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline,
),
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
onLongPress: onLongPress,
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 5,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Hero(
tag: record.id,
child: Center(
child: CachedNetworkImage(
height: double.infinity,
width: double.infinity,
imageUrl: Aux.neosDbToHttp(record.thumbnailUri),
fit: BoxFit.cover,
errorWidget: (context, url, error) => const Center(
child: Icon(
Icons.broken_image,
size: 64,
),
),
placeholder: (context, uri) =>
const Center(child: CircularProgressIndicator()),
),
),
),
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: FormattedText(
record.formattedName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(
height: 4,
),
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),
),
],
),
],
),
),
)
],
),
),
);
}
}

View file

@ -0,0 +1,28 @@
import 'package:contacts_plus_plus/models/records/record.dart';
import 'package:contacts_plus_plus/widgets/formatted_text.dart';
import 'package:flutter/material.dart';
class PathInventoryTile extends StatelessWidget {
const PathInventoryTile({required this.record, required this.onPressed, super.key});
final Record record;
final Function() onPressed;
@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: onPressed,
icon: record.recordType == RecordType.directory ? const Icon(Icons.folder) : const Icon(Icons.link),
label: FormattedText(
record.formattedName,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
);
}
}

View file

@ -16,6 +16,15 @@ class SessionList extends StatefulWidget {
}
class _SessionListState extends State<SessionList> with AutomaticKeepAliveClientMixin {
@override
void didChangeDependencies() {
super.didChangeDependencies();
final sClient = Provider.of<SessionClient>(context, listen: false);
if (sClient.sessionsFuture == null) {
sClient.reloadSessions();
}
}
@override
Widget build(BuildContext context) {
super.build(context);
@ -127,8 +136,10 @@ class _SessionListState extends State<SessionList> with AutomaticKeepAliveClient
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.onSurface.withOpacity(.5),
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(.5),
),
),
),