commit
cb777defaf
21 changed files with 1180 additions and 145 deletions
|
@ -6,7 +6,7 @@ buildscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,13 +17,25 @@ import 'package:http_parser/http_parser.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
|
||||||
class RecordApi {
|
class RecordApi {
|
||||||
static Future<List<Record>> getRecordsAt(ApiClient client, {required String path}) async {
|
static Future<Record> getUserRecord(ApiClient client, {required String recordId, String? user}) async {
|
||||||
final response = await client.get("/users/${client.userId}/records?path=$path");
|
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);
|
client.checkResponse(response);
|
||||||
final body = jsonDecode(response.body) as List;
|
final body = jsonDecode(response.body) as List;
|
||||||
return body.map((e) => Record.fromMap(e)).toList();
|
return body.map((e) => Record.fromMap(e)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteRecord(ApiClient client, {required String recordId}) async {
|
||||||
|
final response = await client.delete("/users/${client.userId}/records/$recordId");
|
||||||
|
client.checkResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<PreprocessStatus> preprocessRecord(ApiClient client, {required Record record}) async {
|
static Future<PreprocessStatus> preprocessRecord(ApiClient client, {required Record record}) async {
|
||||||
final body = jsonEncode(record.toMap());
|
final body = jsonEncode(record.toMap());
|
||||||
final response = await client.post(
|
final response = await client.post(
|
||||||
|
|
|
@ -16,15 +16,20 @@ class ApiClient {
|
||||||
static const String tokenKey = "token";
|
static const String tokenKey = "token";
|
||||||
static const String passwordKey = "password";
|
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 AuthenticationData _authenticationData;
|
||||||
final Logger _logger = Logger("API");
|
final Logger _logger = Logger("API");
|
||||||
|
|
||||||
// Saving the context here feels kinda cringe ngl
|
// Saving the context here feels kinda cringe ngl
|
||||||
final Function() onLogout;
|
final Function() onLogout;
|
||||||
|
final http.Client _client = http.Client();
|
||||||
|
|
||||||
AuthenticationData get authenticationData => _authenticationData;
|
AuthenticationData get authenticationData => _authenticationData;
|
||||||
|
|
||||||
String get userId => _authenticationData.userId;
|
String get userId => _authenticationData.userId;
|
||||||
|
|
||||||
bool get isAuthenticated => _authenticationData.isAuthenticated;
|
bool get isAuthenticated => _authenticationData.isAuthenticated;
|
||||||
|
|
||||||
static Future<AuthenticationData> tryLogin({
|
static Future<AuthenticationData> tryLogin({
|
||||||
|
@ -83,9 +88,8 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
final response = await http.patch(buildFullUri("/userSessions"), headers: {
|
final response =
|
||||||
"Authorization": "neos $userId:$token"
|
await http.patch(buildFullUri("/userSessions"), headers: {"Authorization": "neos $userId:$token"});
|
||||||
});
|
|
||||||
if (response.statusCode < 300) {
|
if (response.statusCode < 300) {
|
||||||
return AuthenticationData(userId: userId, token: token, secretMachineId: machineId, isAuthenticated: true);
|
return AuthenticationData(userId: userId, token: token, secretMachineId: machineId, isAuthenticated: true);
|
||||||
}
|
}
|
||||||
|
@ -135,13 +139,14 @@ class ApiClient {
|
||||||
static void checkResponseCode(http.Response response) {
|
static void checkResponseCode(http.Response response) {
|
||||||
if (response.statusCode < 300) return;
|
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.",
|
429 => "You are being rate limited.",
|
||||||
403 => "You are not authorized to do that.",
|
403 => "You are not authorized to do that.",
|
||||||
404 => "Resource not found.",
|
404 => "Resource not found.",
|
||||||
500 => "Internal server error.",
|
500 => "Internal server error.",
|
||||||
_ => "Unknown Error."
|
_ => "Unknown Error."
|
||||||
}} (${response.statusCode}${kDebugMode ? "|${response.body}" : ""})";
|
}} (${response.statusCode}${kDebugMode && response.body.isNotEmpty ? "|${response.body}" : ""})";
|
||||||
|
|
||||||
FlutterError.reportError(FlutterErrorDetails(exception: error));
|
FlutterError.reportError(FlutterErrorDetails(exception: error));
|
||||||
throw error;
|
throw error;
|
||||||
|
@ -154,7 +159,7 @@ class ApiClient {
|
||||||
Future<http.Response> get(String path, {Map<String, String>? headers}) async {
|
Future<http.Response> get(String path, {Map<String, String>? headers}) async {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers.addAll(authorizationHeader);
|
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}" : ""}");
|
_logger.info("GET $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -163,7 +168,7 @@ class ApiClient {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
headers.addAll(authorizationHeader);
|
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}" : ""}");
|
_logger.info("PST $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -172,7 +177,7 @@ class ApiClient {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
headers.addAll(authorizationHeader);
|
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}" : ""}");
|
_logger.info("PUT $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -180,7 +185,7 @@ class ApiClient {
|
||||||
Future<http.Response> delete(String path, {Map<String, String>? headers}) async {
|
Future<http.Response> delete(String path, {Map<String, String>? headers}) async {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers.addAll(authorizationHeader);
|
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}" : ""}");
|
_logger.info("DEL $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -189,7 +194,7 @@ class ApiClient {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
headers.addAll(authorizationHeader);
|
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}" : ""}");
|
_logger.info("PAT $path => ${response.statusCode}${response.statusCode >= 300 ? ": ${response.body}" : ""}");
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
180
lib/clients/inventory_client.dart
Normal file
180
lib/clients/inventory_client.dart
Normal file
|
@ -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<NeosDirectory>? _currentDirectory;
|
||||||
|
|
||||||
|
Future<NeosDirectory>? get directoryFuture => _currentDirectory;
|
||||||
|
|
||||||
|
InventoryClient({required this.apiClient});
|
||||||
|
|
||||||
|
final Map<String, Record> _selectedRecords = {};
|
||||||
|
|
||||||
|
List<Record> 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<void> 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<List<Record>> _getDirectory(Record record) async {
|
||||||
|
NeosDirectory? dir;
|
||||||
|
try {
|
||||||
|
dir = await _currentDirectory;
|
||||||
|
} catch (_) {}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
void forceNotify() => notifyListeners();
|
||||||
|
|
||||||
|
Future<void> 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<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.";
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -366,6 +366,8 @@ class MessagingClient extends ChangeNotifier {
|
||||||
if (message.senderId != selectedFriend?.id) {
|
if (message.senderId != selectedFriend?.id) {
|
||||||
addUnread(message);
|
addUnread(message);
|
||||||
updateFriendStatus(message.senderId);
|
updateFriendStatus(message.senderId);
|
||||||
|
} else {
|
||||||
|
markMessagesRead(MarkReadBatch(senderId: message.senderId, ids: [message.id], readTime: DateTime.now()));
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -11,9 +11,7 @@ class SessionClient extends ChangeNotifier {
|
||||||
|
|
||||||
SessionFilterSettings _filterSettings = SessionFilterSettings.empty();
|
SessionFilterSettings _filterSettings = SessionFilterSettings.empty();
|
||||||
|
|
||||||
SessionClient({required this.apiClient}) {
|
SessionClient({required this.apiClient});
|
||||||
reloadSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionFilterSettings get filterSettings => _filterSettings;
|
SessionFilterSettings get filterSettings => _filterSettings;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class Config {
|
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 legacyCloudUrl = "https://neoscloud.blob.core.windows.net/assets/";
|
||||||
static const String blobStorageUrl = "https://cloudxstorage.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/";
|
static const String videoStorageUrl = "https://cloudx-video.azureedge.net/";
|
||||||
|
|
|
@ -3,20 +3,22 @@ import 'dart:developer';
|
||||||
import 'package:contacts_plus_plus/apis/github_api.dart';
|
import 'package:contacts_plus_plus/apis/github_api.dart';
|
||||||
import 'package:contacts_plus_plus/client_holder.dart';
|
import 'package:contacts_plus_plus/client_holder.dart';
|
||||||
import 'package:contacts_plus_plus/clients/api_client.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/messaging_client.dart';
|
||||||
import 'package:contacts_plus_plus/clients/session_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/clients/settings_client.dart';
|
||||||
import 'package:contacts_plus_plus/models/sem_ver.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/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/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/sessions/session_list_app_bar.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/settings_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:contacts_plus_plus/widgets/update_notifier.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||||
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
@ -28,20 +30,29 @@ import 'models/authentication_data.dart';
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
await FlutterDownloader.initialize(
|
||||||
|
debug: kDebugMode,
|
||||||
|
);
|
||||||
|
|
||||||
Provider.debugCheckInvalidValueType = null;
|
Provider.debugCheckInvalidValueType = null;
|
||||||
|
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
|
|
||||||
final dateFormat = DateFormat.Hms();
|
final dateFormat = DateFormat.Hms();
|
||||||
Logger.root.onRecord.listen(
|
Logger.root.onRecord.listen(
|
||||||
(event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
|
(event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
|
||||||
|
|
||||||
final settingsClient = SettingsClient();
|
final settingsClient = SettingsClient();
|
||||||
await settingsClient.loadSettings();
|
await settingsClient.loadSettings();
|
||||||
final newSettings =
|
final newSettings =
|
||||||
settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault);
|
settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault);
|
||||||
await settingsClient.changeSettings(newSettings); // Save generated machineId to disk
|
await settingsClient.changeSettings(newSettings); // Save generated machineId to disk
|
||||||
|
|
||||||
AuthenticationData cachedAuth = AuthenticationData.unauthenticated();
|
AuthenticationData cachedAuth = AuthenticationData.unauthenticated();
|
||||||
try {
|
try {
|
||||||
cachedAuth = await ApiClient.tryCachedLogin();
|
cachedAuth = await ApiClient.tryCachedLogin();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth));
|
runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,24 +67,9 @@ class ContactsPlusPlus extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
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"),
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
||||||
final PageController _pageController = PageController();
|
|
||||||
late AuthenticationData _authData = widget.cachedAuthentication;
|
late AuthenticationData _authData = widget.cachedAuthentication;
|
||||||
|
|
||||||
bool _checkedForUpdate = false;
|
bool _checkedForUpdate = false;
|
||||||
int _selectedPage = 0;
|
|
||||||
|
|
||||||
void showUpdateDialogOnFirstBuild(BuildContext context) {
|
void showUpdateDialogOnFirstBuild(BuildContext context) {
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
|
@ -171,58 +167,13 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
||||||
apiClient: clientHolder.apiClient,
|
apiClient: clientHolder.apiClient,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Provider(
|
||||||
|
create: (context) => InventoryClient(
|
||||||
|
apiClient: clientHolder.apiClient,
|
||||||
|
),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
child: Scaffold(
|
child: const Home(),
|
||||||
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",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: LoginScreen(
|
: LoginScreen(
|
||||||
onLoginSuccessful: (AuthenticationData authData) async {
|
onLoginSuccessful: (AuthenticationData authData) async {
|
||||||
|
|
64
lib/models/inventory/neos_path.dart
Normal file
64
lib/models/inventory/neos_path.dart
Normal 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);
|
||||||
|
}
|
|
@ -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/models/records/neos_db_asset.dart';
|
||||||
import 'package:contacts_plus_plus/string_formatter.dart';
|
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
enum RecordType {
|
enum RecordType {
|
||||||
|
@ -15,7 +16,8 @@ enum RecordType {
|
||||||
audio;
|
audio;
|
||||||
|
|
||||||
factory RecordType.fromName(String? name) {
|
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 String? ownerId;
|
||||||
final bool isValid;
|
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) {
|
factory RecordId.fromMap(Map? map) {
|
||||||
return RecordId(id: map?["id"], ownerId: map?["ownerId"], isValid: map?["isValid"] ?? false);
|
return RecordId(id: map?["id"], ownerId: map?["ownerId"], isValid: map?["isValid"] ?? false);
|
||||||
|
@ -40,6 +42,38 @@ class RecordId {
|
||||||
}
|
}
|
||||||
|
|
||||||
class Record {
|
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 String id;
|
||||||
final RecordId combinedRecordId;
|
final RecordId combinedRecordId;
|
||||||
final String ownerId;
|
final String ownerId;
|
||||||
|
@ -119,12 +153,8 @@ class Record {
|
||||||
combinedRecordId: combinedRecordId,
|
combinedRecordId: combinedRecordId,
|
||||||
assetUri: assetUri,
|
assetUri: assetUri,
|
||||||
name: filename,
|
name: filename,
|
||||||
tags: ([
|
tags: ([filename, "message_item", "message_id:${Message.generateId()}", "contacts-plus-plus"] + (extraTags ?? []))
|
||||||
filename,
|
.unique(),
|
||||||
"message_item",
|
|
||||||
"message_id:${Message.generateId()}",
|
|
||||||
"contacts-plus-plus"
|
|
||||||
] + (extraTags ?? [])).unique(),
|
|
||||||
recordType: recordType,
|
recordType: recordType,
|
||||||
thumbnailUri: thumbnailUri,
|
thumbnailUri: thumbnailUri,
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
|
@ -174,16 +204,47 @@ class Record {
|
||||||
lastModifyingMachineId: map["lastModifyingMachineId"] ?? "",
|
lastModifyingMachineId: map["lastModifyingMachineId"] ?? "",
|
||||||
creationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch,
|
creationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch,
|
||||||
isSynced: map["isSynced"] ?? false,
|
isSynced: map["isSynced"] ?? false,
|
||||||
fetchedOn: DateTime.tryParse(map["fetchedOn"]) ?? DateTimeX.epoch,
|
fetchedOn: DateTime.tryParse(map["fetchedOn"] ?? "") ?? DateTimeX.epoch,
|
||||||
path: map["path"] ?? "",
|
path: map["path"] ?? "",
|
||||||
manifest: (map["neosDBManifest"] as List? ?? []).map((e) => e.toString()).toList(),
|
manifest: (map["neosDBManifest"] as List? ?? []).map((e) => e.toString()).toList(),
|
||||||
url: map["url"] ?? "",
|
url: map["url"] ?? "",
|
||||||
isValidOwnerId: map["isValidOwnerId"] ?? "",
|
isValidOwnerId: map["isValidOwnerId"] == "true",
|
||||||
isValidRecordId: map["isValidRecordId"] ?? "",
|
isValidRecordId: map["isValidRecordId"] == "true",
|
||||||
visits: map["visits"] ?? 0,
|
visits: map["visits"] ?? 0,
|
||||||
rating: map["rating"] ?? 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({
|
Record copyWith({
|
||||||
|
|
14
lib/stack.dart
Normal file
14
lib/stack.dart
Normal 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;
|
||||||
|
}
|
|
@ -57,6 +57,11 @@ class FormatNode {
|
||||||
return spanTree;
|
return spanTree;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return text + children.join();
|
||||||
|
}
|
||||||
|
|
||||||
static FormatNode buildFromStyles(List<FormatData> styles, String text) {
|
static FormatNode buildFromStyles(List<FormatData> styles, String text) {
|
||||||
if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text);
|
if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text);
|
||||||
final root = FormatNode(text: "", format: styles.first, children: []);
|
final root = FormatNode(text: "", format: styles.first, children: []);
|
||||||
|
|
|
@ -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/models/users/user_status.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/friends/user_search.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/my_profile_dialog.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/settings_page.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -156,13 +155,6 @@ class _FriendsListAppBarState extends State<FriendsListAppBar> with AutomaticKee
|
||||||
await itemDef.onTap();
|
await itemDef.onTap();
|
||||||
},
|
},
|
||||||
itemBuilder: (BuildContext context) => [
|
itemBuilder: (BuildContext context) => [
|
||||||
MenuItemDefinition(
|
|
||||||
name: "Settings",
|
|
||||||
icon: Icons.settings,
|
|
||||||
onTap: () async {
|
|
||||||
await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage()));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
name: "Find Users",
|
name: "Find Users",
|
||||||
icon: Icons.person_add,
|
icon: Icons.person_add,
|
||||||
|
|
91
lib/widgets/homepage.dart
Normal file
91
lib/widgets/homepage.dart
Normal 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",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
236
lib/widgets/inventory/inventory_browser.dart
Normal file
236
lib/widgets/inventory/inventory_browser.dart
Normal file
|
@ -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<StatefulWidget> createState() => _InventoryBrowserState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepAliveClientMixin {
|
||||||
|
static const Duration _refreshLimit = Duration(seconds: 60);
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
}
|
249
lib/widgets/inventory/inventory_browser_app_bar.dart
Normal file
249
lib/widgets/inventory/inventory_browser_app_bar.dart
Normal file
|
@ -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<InventoryBrowserAppBar> createState() => _InventoryBrowserAppBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InventoryBrowserAppBarState extends State<InventoryBrowserAppBar> {
|
||||||
|
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<InventoryClient>(context),
|
||||||
|
child: Consumer<InventoryClient>(
|
||||||
|
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<List<String>>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
107
lib/widgets/inventory/object_inventory_tile.dart
Normal file
107
lib/widgets/inventory/object_inventory_tile.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
48
lib/widgets/inventory/path_inventory_tile.dart
Normal file
48
lib/widgets/inventory/path_inventory_tile.dart
Normal file
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,15 @@ class SessionList extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SessionListState extends State<SessionList> with AutomaticKeepAliveClientMixin {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
@ -127,8 +136,10 @@ class _SessionListState extends State<SessionList> with AutomaticKeepAliveClient
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color:
|
color: Theme.of(context)
|
||||||
Theme.of(context).colorScheme.onSurface.withOpacity(.5),
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withOpacity(.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -230,6 +230,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.0"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -63,6 +63,7 @@ dependencies:
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
image_picker: ^0.8.7+5
|
image_picker: ^0.8.7+5
|
||||||
permission_handler: ^10.2.0
|
permission_handler: ^10.2.0
|
||||||
|
flutter_downloader: ^1.10.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
Loading…
Reference in a new issue