diff --git a/android/build.gradle b/android/build.gradle index 713d7f6..ce647a4 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.2.0' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index f8c07b3..7575c20 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -17,13 +17,25 @@ 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(); } + static Future deleteRecord(ApiClient client, {required String recordId}) async { + final response = await client.delete("/users/${client.userId}/records/$recordId"); + client.checkResponse(response); + } + static Future preprocessRecord(ApiClient client, {required Record record}) async { final body = jsonEncode(record.toMap()); final response = await client.post( 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..930612f --- /dev/null +++ b/lib/clients/inventory_client.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +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}); + + final Map _selectedRecords = {}; + + List get selectedRecords => _selectedRecords.values.toList(); + + bool get isAnyRecordSelected => _selectedRecords.isNotEmpty; + + bool isRecordSelected(Record record) => _selectedRecords.containsKey(record.id); + + int get selectedRecordCount => _selectedRecords.length; + + bool get onlyFilesSelected => _selectedRecords.values + .every((element) => element.recordType != RecordType.link && element.recordType != RecordType.directory); + + void clearSelectedRecords() { + _selectedRecords.clear(); + notifyListeners(); + } + + Future deleteSelectedRecords() async { + for (final recordId in _selectedRecords.keys) { + await RecordApi.deleteRecord(apiClient, recordId: recordId); + } + _selectedRecords.clear(); + reloadCurrentDirectory(); + } + + void toggleRecordSelected(Record record) { + if (_selectedRecords.containsKey(record.id)) { + _selectedRecords.remove(record.id); + } else { + _selectedRecords[record.id] = record; + } + notifyListeners(); + } + + Future> _getDirectory(Record record) async { + NeosDirectory? dir; + try { + dir = await _currentDirectory; + } catch (_) {} + 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; + } + + void forceNotify() => notifyListeners(); + + Future reloadCurrentDirectory() async { + final dir = await _currentDirectory; + + if (dir == null) { + throw "Failed to reload: No directory loaded."; + } + + _currentDirectory = _getDirectory(dir.record).then( + (records) { + final children = records.map((record) => NeosDirectory.fromRecord(record: record, parent: dir)).toList(); + final newDir = NeosDirectory(record: dir.record, children: children, parent: dir.parent); + + final parentIdx = dir.parent?.children.indexOf(dir) ?? -1; + if (parentIdx != -1) { + dir.parent?.children[parentIdx] = newDir; + } + return newDir; + }, + ).onError((error, stackTrace) { + return dir; + }); + notifyListeners(); + } + + 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."; + } + + Object? caughtError; + + 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; + }, + ).onError((error, stackTrace) { + caughtError = error; + return dir; + }); + } + notifyListeners(); + await _currentDirectory; + // Dirty hack to throw the error here instead of letting the FutureBuilder handle it. This means we can keep showing + // the previous directory while also being able to display the error as a snackbar. + if (caughtError != null) { + throw caughtError!; + } + } + + Future navigateUp({int times = 1}) async { + if (times == 0) return; + + var 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"; + } + + for (int i = 0; i < times; i++) { + dir = dir?.parent; + } + + _currentDirectory = Future.value(dir); + 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/config.dart b/lib/config.dart index d392f16..d8162d7 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -1,5 +1,5 @@ class Config { - static const String apiBaseUrl = "https://cloudx.azurewebsites.net"; + static const String apiBaseUrl = "https://api.neos.com"; static const String legacyCloudUrl = "https://neoscloud.blob.core.windows.net/assets/"; static const String blobStorageUrl = "https://cloudxstorage.blob.core.windows.net/assets/"; static const String videoStorageUrl = "https://cloudx-video.azureedge.net/"; diff --git a/lib/main.dart b/lib/main.dart index dee27e1..d9ddb7e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,20 +3,22 @@ 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_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'; import 'package:contacts_plus_plus/widgets/settings_app_bar.dart'; -import 'package:contacts_plus_plus/widgets/settings_page.dart'; import 'package:contacts_plus_plus/widgets/update_notifier.dart'; import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; @@ -28,20 +30,29 @@ import 'models/authentication_data.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await FlutterDownloader.initialize( + debug: kDebugMode, + ); + Provider.debugCheckInvalidValueType = null; + await Hive.initFlutter(); + final dateFormat = DateFormat.Hms(); Logger.root.onRecord.listen( (event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time)); + final settingsClient = SettingsClient(); await settingsClient.loadSettings(); final newSettings = settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault); await settingsClient.changeSettings(newSettings); // Save generated machineId to disk + AuthenticationData cachedAuth = AuthenticationData.unauthenticated(); try { cachedAuth = await ApiClient.tryCachedLogin(); } catch (_) {} + runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth)); } @@ -56,24 +67,9 @@ 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"), - ) - ]; - 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/string_formatter.dart b/lib/string_formatter.dart index 55c54d9..e853894 100644 --- a/lib/string_formatter.dart +++ b/lib/string_formatter.dart @@ -57,6 +57,11 @@ class FormatNode { return spanTree; } + @override + String toString() { + return text + children.join(); + } + static FormatNode buildFromStyles(List styles, String text) { if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text); final root = FormatNode(text: "", format: styles.first, children: []); 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..653c460 --- /dev/null +++ b/lib/widgets/inventory/inventory_browser.dart @@ -0,0 +1,236 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.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); + 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 { + await iClient.reloadCurrentDirectory(); + _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: () { + iClient.loadInventoryRoot(); + iClient.forceNotify(); + }, + ); + } + 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(); + final pathSegments = directory?.absolutePathSegments ?? []; + return Stack( + children: [ + ListView( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: Wrap( + children: pathSegments + .mapIndexed( + (idx, segment) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (idx != 0) const Icon(Icons.chevron_right), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: idx == pathSegments.length - 1 + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + ), + onPressed: () { + iClient.navigateUp(times: pathSegments.length - 1 - idx); + }, + child: Text(segment), + ), + ), + ], + ), + ) + .toList(), + ), + ), + GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: paths.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 256, + childAspectRatio: 3.5, + crossAxisSpacing: 0, + mainAxisSpacing: 0), + itemBuilder: (context, index) { + final record = paths[index]; + return PathInventoryTile( + record: record, + onTap: iClient.isAnyRecordSelected + ? () {} + : () async { + try { + await iClient.navigateTo(record); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Failed to open directory: $e")), + ); + } + } + }, + onLongPress: () { + iClient.toggleRecordSelected(record); + }, + ); + }, + ), + const SizedBox( + height: 0, + ), + GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: objects.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 256, + childAspectRatio: 1, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemBuilder: (context, index) { + final record = objects[index]; + return ObjectInventoryTile( + record: record, + selected: iClient.isRecordSelected(record), + onTap: iClient.isAnyRecordSelected + ? () async { + iClient.toggleRecordSelected(record); + } + : () 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 { + iClient.toggleRecordSelected(record); + }, + ); + }, + ), + ], + ), + Align( + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: snapshot.connectionState == ConnectionState.waiting + ? const LinearProgressIndicator() + : null, + ), + ), + Align( + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: snapshot.connectionState == ConnectionState.waiting + ? Container( + width: double.infinity, + height: double.infinity, + color: Colors.black38, + ) + : null, + ), + ) + ], + ); + }, + ), + ), + ); + }); + }), + ); + } + + @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..5722847 --- /dev/null +++ b/lib/widgets/inventory/inventory_browser_app_bar.dart @@ -0,0 +1,249 @@ +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/clients/inventory_client.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:path/path.dart'; +import 'package:provider/provider.dart'; + +class InventoryBrowserAppBar extends StatefulWidget { + const InventoryBrowserAppBar({super.key}); + + @override + State createState() => _InventoryBrowserAppBarState(); +} + +class _InventoryBrowserAppBarState extends State { + final ReceivePort _port = ReceivePort(); + + @override + void initState() { + super.initState(); + + IsolateNameServer.registerPortWithName(_port.sendPort, 'downloader_send_port'); + _port.listen((dynamic data) { + // Not useful yet? idk... + String id = data[0]; + DownloadTaskStatus status = DownloadTaskStatus(data[1]); + int progress = data[2]; + }); + + FlutterDownloader.registerCallback(downloadCallback); + } + + @override + void dispose() { + IsolateNameServer.removePortNameMapping('downloader_send_port'); + super.dispose(); + } + + @pragma('vm:entry-point') + static void downloadCallback(String id, int status, int progress) { + final SendPort? send = IsolateNameServer.lookupPortByName('downloader_send_port'); + send?.send([id, status, progress]); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: Provider.of(context), + child: Consumer( + builder: (BuildContext context, InventoryClient iClient, Widget? child) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + child: !iClient.isAnyRecordSelected + ? AppBar( + key: const ValueKey("default-appbar"), + title: const Text("Inventory"), + ) + : AppBar( + key: const ValueKey("selection-appbar"), + title: Text("${iClient.selectedRecordCount} Selected"), + leading: IconButton( + onPressed: () { + iClient.clearSelectedRecords(); + }, + icon: const Icon(Icons.close), + ), + actions: [ + if (iClient.onlyFilesSelected) + IconButton( + onPressed: () async { + final selectedRecords = iClient.selectedRecords; + + final assetUris = selectedRecords.map((record) => record.assetUri).toList(); + final thumbUris = selectedRecords.map((record) => record.thumbnailUri).toList(); + + final selectedUris = await showDialog>( + context: context, + builder: (context) { + return AlertDialog( + icon: const Icon(Icons.download), + title: const Text("Download what?"), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(), + const SizedBox( + height: 8, + ), + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(assetUris); + }, + icon: const Icon(Icons.data_object), + label: Text( + "Asset${iClient.selectedRecordCount != 1 ? "s" : ""} (${assetUris.map((e) => extension(e)).toList().unique().join(", ")})", + ), + ), + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(thumbUris); + }, + icon: const Icon(Icons.image), + label: Text( + "Thumbnail${iClient.selectedRecordCount != 1 ? "s" : ""} (${thumbUris.map((e) => extension(e)).toList().unique().join(", ")})", + ), + ), + ], + ), + ); + }, + ); + if (selectedUris == null) return; + + final directory = await FilePicker.platform.getDirectoryPath(dialogTitle: "Download to..."); + if (directory == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Selection aborted."), + ), + ); + } + return; + } + if (directory == "/") { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Selected directory is invalid"), + ), + ); + } + return; + } + for (var record in selectedRecords) { + final uri = selectedUris == thumbUris ? record.thumbnailUri : record.thumbnailUri; + await FlutterDownloader.enqueue( + url: Aux.neosDbToHttp(uri), + savedDir: directory, + showNotification: true, + openFileFromNotification: false, + fileName: + "${record.id.split("-")[1]}-${record.formattedName.toString()}${extension(uri)}", + ); + } + iClient.clearSelectedRecords(); + }, + icon: const Icon(Icons.download), + ), + const SizedBox( + width: 4, + ), + IconButton( + onPressed: () async { + var loading = false; + await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + icon: const Icon(Icons.delete), + title: Text(iClient.selectedRecordCount == 1 + ? "Really delete this Record?" + : "Really delete ${iClient.selectedRecordCount} Records?"), + content: const Text("This action cannot be undone!"), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + TextButton( + onPressed: loading + ? null + : () { + Navigator.of(context).pop(false); + }, + child: const Text("Cancel"), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (loading) + const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox( + width: 4, + ), + TextButton( + onPressed: loading + ? null + : () async { + setState(() { + loading = true; + }); + try { + await iClient.deleteSelectedRecords(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Failed to delete one or more records: $e"), + ), + ); + } + setState(() { + loading = false; + }); + } + if (context.mounted) { + Navigator.of(context).pop(true); + } + iClient.reloadCurrentDirectory(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text("Delete"), + ), + ], + ), + ], + ); + }, + ); + }, + ); + }, + icon: const Icon(Icons.delete), + ), + const SizedBox( + width: 4, + ), + ], + ), + ); + }, + ), + ); + } +} 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..f88977a --- /dev/null +++ b/lib/widgets/inventory/path_inventory_tile.dart @@ -0,0 +1,48 @@ +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, this.selected = false, this.onTap, this.onLongPress, super.key}); + + final Record record; + final Function()? onTap; + final Function()? onLongPress; + final bool selected; + + @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( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0), + child: Row( + children: [ + record.recordType == RecordType.directory ? const Icon(Icons.folder) : const Icon(Icons.link), + const SizedBox( + width: 4, + ), + Expanded( + child: FormattedText( + record.formattedName, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} 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), ), ), ), diff --git a/pubspec.lock b/pubspec.lock index eb9527d..4183ae3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -230,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0" + flutter_downloader: + dependency: "direct main" + description: + name: flutter_downloader + sha256: "79e05335471e23593f2e22483d2c909a03f19000293cbc7f39c8c2fd4d5d9c3d" + url: "https://pub.dev" + source: hosted + version: "1.10.4" flutter_lints: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index d9c769a..3e4423f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: crypto: ^3.0.3 image_picker: ^0.8.7+5 permission_handler: ^10.2.0 + flutter_downloader: ^1.10.4 dev_dependencies: flutter_test: