diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index f8c07b3..275f8dd 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -17,8 +17,15 @@ import 'package:http_parser/http_parser.dart'; import 'package:path/path.dart'; class RecordApi { - static Future> getRecordsAt(ApiClient client, {required String path}) async { - final response = await client.get("/users/${client.userId}/records?path=$path"); + static Future 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> 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(); diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index 0cddec0..e083d43 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -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 tryLogin({ required String username, required String password, - bool rememberMe=true, - bool rememberPass=true, + bool rememberMe = true, + bool rememberPass = true, String? oneTimePad, }) async { final body = { @@ -41,19 +46,19 @@ class ApiClient { "secretMachineId": const Uuid().v4(), }; final response = await http.post( - buildFullUri("/UserSessions"), - headers: { - "Content-Type": "application/json", - if (oneTimePad != null) totpKey : oneTimePad, - }, - body: jsonEncode(body), + buildFullUri("/UserSessions"), + headers: { + "Content-Type": "application/json", + if (oneTimePad != null) totpKey: oneTimePad, + }, + body: jsonEncode(body), ); if (response.statusCode == 403 && response.body == totpKey) { throw totpKey; } if (response.statusCode == 400) { throw "Invalid Credentials"; - } + } checkResponseCode(response); final authData = AuthenticationData.fromMap(jsonDecode(response.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 get(String path, {Map? 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 delete(String path, {Map? 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; } diff --git a/lib/clients/inventory_client.dart b/lib/clients/inventory_client.dart new file mode 100644 index 0000000..0307beb --- /dev/null +++ b/lib/clients/inventory_client.dart @@ -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? _currentDirectory; + + Future? get directoryFuture => _currentDirectory; + + InventoryClient({required this.apiClient}); + + Future> _getDirectory(Record record) async { + final dir = await _currentDirectory; + final List 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 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 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(); + } +} diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 2297d25..97a85e2 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -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; diff --git a/lib/clients/session_client.dart b/lib/clients/session_client.dart index 37ff518..6bc4161 100644 --- a/lib/clients/session_client.dart +++ b/lib/clients/session_client.dart @@ -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; diff --git a/lib/main.dart b/lib/main.dart index dee27e1..6658d05 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { static const List _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 { 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 { diff --git a/lib/models/inventory/neos_path.dart b/lib/models/inventory/neos_path.dart new file mode 100644 index 0000000..4f26285 --- /dev/null +++ b/lib/models/inventory/neos_path.dart @@ -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 _pathStack = Stack(); + + 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 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 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 get absolutePathSegments => (parent?.absolutePathSegments ?? []) + [record.name]; + + bool containsRecord(Record record) => children.where((element) => element.record.id == record.id).isNotEmpty; + + List 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); +} diff --git a/lib/models/records/record.dart b/lib/models/records/record.dart index 125a0c6..e90fe38 100644 --- a/lib/models/records/record.dart +++ b/lib/models/records/record.dart @@ -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, @@ -154,36 +184,67 @@ class Record { factory Record.fromMap(Map map) { return Record( - id: map["id"] ?? "0", - combinedRecordId: RecordId.fromMap(map["combinedRecordId"]), - ownerId: map["ownerId"] ?? "", - assetUri: map["assetUri"] ?? "", - globalVersion: map["globalVersion"] ?? 0, - localVersion: map["localVersion"] ?? 0, - name: map["name"] ?? "", - description: map["description"] ?? "", - 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, - isListed: map["isListed"] ?? false, - lastModificationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch, - neosDBManifest: (map["neosDBManifest"] as List? ?? []).map((e) => NeosDBAsset.fromMap(e)).toList(), - lastModifyingUserId: map["lastModifyingUserId"] ?? "", - lastModifyingMachineId: map["lastModifyingMachineId"] ?? "", - creationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch, - isSynced: map["isSynced"] ?? false, - 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"] ?? "", - visits: map["visits"] ?? 0, - rating: map["rating"] ?? 0, - randomOrder: map["randomOrder"] ?? 0 - ); + id: map["id"] ?? "0", + combinedRecordId: RecordId.fromMap(map["combinedRecordId"]), + ownerId: map["ownerId"] ?? "", + assetUri: map["assetUri"] ?? "", + globalVersion: map["globalVersion"] ?? 0, + localVersion: map["localVersion"] ?? 0, + name: map["name"] ?? "", + description: map["description"] ?? "", + 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, + isListed: map["isListed"] ?? false, + lastModificationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch, + neosDBManifest: (map["neosDBManifest"] as List? ?? []).map((e) => NeosDBAsset.fromMap(e)).toList(), + lastModifyingUserId: map["lastModifyingUserId"] ?? "", + lastModifyingMachineId: map["lastModifyingMachineId"] ?? "", + creationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch, + isSynced: map["isSynced"] ?? false, + 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"] == "true", + isValidRecordId: map["isValidRecordId"] == "true", + visits: map["visits"] ?? 0, + rating: map["rating"] ?? 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({ @@ -300,4 +361,4 @@ class Record { } return null; } -} \ No newline at end of file +} diff --git a/lib/stack.dart b/lib/stack.dart new file mode 100644 index 0000000..b7bc9d1 --- /dev/null +++ b/lib/stack.dart @@ -0,0 +1,14 @@ + +class Stack { + final List _data = []; + + void push(T entry) => _data.add(entry); + + T pop() => _data.removeLast(); + + T? get peek => _data.lastOrNull; + + List get entries => List.from(_data); + + bool get isEmpty => _data.isEmpty; +} \ No newline at end of file diff --git a/lib/widgets/friends/friends_list_app_bar.dart b/lib/widgets/friends/friends_list_app_bar.dart index 07b2ab1..367411b 100644 --- a/lib/widgets/friends/friends_list_app_bar.dart +++ b/lib/widgets/friends/friends_list_app_bar.dart @@ -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 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, diff --git a/lib/widgets/homepage.dart b/lib/widgets/homepage.dart new file mode 100644 index 0000000..a8035db --- /dev/null +++ b/lib/widgets/homepage.dart @@ -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 createState() => _HomeState(); +} + +class _HomeState extends State { + static const List _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", + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/inventory/inventory_browser.dart b/lib/widgets/inventory/inventory_browser.dart new file mode 100644 index 0000000..d2ae8b5 --- /dev/null +++ b/lib/widgets/inventory/inventory_browser.dart @@ -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 createState() => _InventoryBrowserState(); +} + +class _InventoryBrowserState extends State with AutomaticKeepAliveClientMixin { + static const Duration _refreshLimit = Duration(seconds: 60); + final Set _selectedIds = {}; + Timer? _refreshLimiter; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final iClient = Provider.of(context, listen: false); + if (iClient.directoryFuture == null) { + iClient.loadInventoryRoot(); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return ChangeNotifierProvider.value( + value: Provider.of(context), + child: Consumer( + builder: (BuildContext context, InventoryClient iClient, Widget? child) { + return FutureBuilder( + 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; +} diff --git a/lib/widgets/inventory/inventory_browser_app_bar.dart b/lib/widgets/inventory/inventory_browser_app_bar.dart new file mode 100644 index 0000000..fe1fd43 --- /dev/null +++ b/lib/widgets/inventory/inventory_browser_app_bar.dart @@ -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, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/inventory/object_inventory_tile.dart b/lib/widgets/inventory/object_inventory_tile.dart new file mode 100644 index 0000000..54bffe0 --- /dev/null +++ b/lib/widgets/inventory/object_inventory_tile.dart @@ -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), + ), + ], + ), + ], + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/widgets/inventory/path_inventory_tile.dart b/lib/widgets/inventory/path_inventory_tile.dart new file mode 100644 index 0000000..003d3fa --- /dev/null +++ b/lib/widgets/inventory/path_inventory_tile.dart @@ -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, + ), + ); + } +} diff --git a/lib/widgets/sessions/session_list.dart b/lib/widgets/sessions/session_list.dart index a221e2f..0f8ee1f 100644 --- a/lib/widgets/sessions/session_list.dart +++ b/lib/widgets/sessions/session_list.dart @@ -16,6 +16,15 @@ class SessionList extends StatefulWidget { } class _SessionListState extends State with AutomaticKeepAliveClientMixin { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final sClient = Provider.of(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 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), ), ), ),