Compare commits
9 commits
main
...
asset-hand
Author | SHA1 | Date | |
---|---|---|---|
|
18ce8cf2cb | ||
|
a823604822 | ||
|
aabb23f326 | ||
|
0a73acc35c | ||
|
2d3970ecf0 | ||
|
d617cd3337 | ||
|
32d8333c7c | ||
|
56ae580329 | ||
|
f2db69331b |
34 changed files with 1847 additions and 444 deletions
14
README.md
14
README.md
|
@ -6,6 +6,20 @@ Messenger App for Neos VR contacts.
|
|||
|
||||
[Get it here](https://github.com/Nutcake/contacts-plus-plus/releases/latest)
|
||||
|
||||
## What works
|
||||
|
||||
- Real-time text chat with friends
|
||||
- Listening to voice-messages and viewing session invites, images and previews of other assets
|
||||
- Searching for users
|
||||
- Adding and removing friends
|
||||
|
||||
## What's planned
|
||||
|
||||
- Sending voice-messages, images and potentially arbitrary files
|
||||
- Background notifications
|
||||
- Global session-list and search
|
||||
- Viewing personal inventory
|
||||
|
||||
## Building
|
||||
|
||||
This is a standard Flutter application, refer to the [Flutter docs](https://docs.flutter.dev/get-started/install) on how to build it.
|
||||
|
|
114
lib/apis/asset_api.dart
Normal file
114
lib/apis/asset_api.dart
Normal file
|
@ -0,0 +1,114 @@
|
|||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:contacts_plus_plus/models/message.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||
import 'package:contacts_plus_plus/models/asset/asset_upload_data.dart';
|
||||
import 'package:contacts_plus_plus/models/asset/neos_db_asset.dart';
|
||||
import 'package:contacts_plus_plus/models/asset/preprocess_status.dart';
|
||||
import 'package:contacts_plus_plus/models/asset/record.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
class AssetApi {
|
||||
static Future<List<Record>> getRecordsAt(ApiClient client, {required String path}) async {
|
||||
final response = await client.get("/users/${client.userId}/records?path=$path");
|
||||
ApiClient.checkResponse(response);
|
||||
final body = jsonDecode(response.body) as List;
|
||||
return body.map((e) => Record.fromMap(e)).toList();
|
||||
}
|
||||
|
||||
static Future<PreprocessStatus> preprocessRecord(ApiClient client, {required Record record}) async {
|
||||
final response = await client.post(
|
||||
"/users/${record.ownerId}/records/${record.id}/preprocess", body: jsonEncode(record.toMap()));
|
||||
ApiClient.checkResponse(response);
|
||||
final body = jsonDecode(response.body);
|
||||
return PreprocessStatus.fromMap(body);
|
||||
}
|
||||
|
||||
static Future<PreprocessStatus> getPreprocessStatus(ApiClient client,
|
||||
{required PreprocessStatus preprocessStatus}) async {
|
||||
final response = await client.get(
|
||||
"/users/${preprocessStatus.ownerId}/records/${preprocessStatus.recordId}/preprocess/${preprocessStatus.id}"
|
||||
);
|
||||
ApiClient.checkResponse(response);
|
||||
final body = jsonDecode(response.body);
|
||||
return PreprocessStatus.fromMap(body);
|
||||
}
|
||||
|
||||
static Future<AssetUploadData> beginUploadAsset(ApiClient client, {required NeosDBAsset asset}) async {
|
||||
final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks?bytes=${asset.bytes}");
|
||||
ApiClient.checkResponse(response);
|
||||
final body = jsonDecode(response.body);
|
||||
final res = AssetUploadData.fromMap(body);
|
||||
if (res.uploadState == UploadState.failed) throw body;
|
||||
return res;
|
||||
}
|
||||
|
||||
static Future<void> upsertRecord(ApiClient client, {required Record record}) async {
|
||||
final body = jsonEncode(record.toMap());
|
||||
final response = await client.put("/users/${client.userId}/records/${record.id}", body: body);
|
||||
ApiClient.checkResponse(response);
|
||||
}
|
||||
|
||||
static Future<String> uploadAssets(ApiClient client, {required NeosDBAsset asset, required Uint8List data}) async {
|
||||
final request = http.MultipartRequest(
|
||||
"POST",
|
||||
ApiClient.buildFullUri("/users/${client.userId}/assets/${asset.hash}"),
|
||||
)
|
||||
..files.add(http.MultipartFile.fromBytes("file", data));
|
||||
final response = await request.send();
|
||||
final body = jsonDecode(await response.stream.bytesToString());
|
||||
return body;
|
||||
}
|
||||
|
||||
static Future<Record> uploadFile(ApiClient client, {required File file, required String machineId}) async {
|
||||
final data = await file.readAsBytes();
|
||||
final asset = NeosDBAsset.fromData(data);
|
||||
final assetUri = "local://$machineId/${asset.hash}${extension(file.path)}";
|
||||
final record = Record(
|
||||
id: Record.generateId(),
|
||||
assetUri: assetUri,
|
||||
name: basenameWithoutExtension(file.path),
|
||||
tags: [
|
||||
"message_item",
|
||||
"message_id:${Message.generateId()}"
|
||||
],
|
||||
recordType: RecordType.texture,
|
||||
thumbnailUri: assetUri,
|
||||
isPublic: false,
|
||||
isForPatreons: false,
|
||||
isListed: false,
|
||||
isDeleted: false,
|
||||
neosDBManifest: [
|
||||
asset,
|
||||
],
|
||||
localVersion: 1,
|
||||
lastModifyingUserId: client.userId,
|
||||
lastModifyingMachineId: machineId,
|
||||
lastModificationTime: DateTime.now().toUtc(),
|
||||
creationTime: DateTime.now().toUtc(),
|
||||
ownerId: client.userId,
|
||||
);
|
||||
|
||||
var status = await preprocessRecord(client, record: record);
|
||||
while (status.state == RecordPreprocessState.preprocessing) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
status = await getPreprocessStatus(client, preprocessStatus: status);
|
||||
}
|
||||
|
||||
if (status.state != RecordPreprocessState.success) {
|
||||
throw "Record Preprocessing failed: ${status.failReason}";
|
||||
}
|
||||
|
||||
final uploadData = await beginUploadAsset(client, asset: asset);
|
||||
if (uploadData.uploadState == UploadState.failed) {
|
||||
throw "Asset upload failed: ${uploadData.uploadState.name}";
|
||||
}
|
||||
|
||||
await uploadAssets(client, asset: asset, data: data);
|
||||
return record;
|
||||
}
|
||||
}
|
23
lib/apis/session_api.dart
Normal file
23
lib/apis/session_api.dart
Normal file
|
@ -0,0 +1,23 @@
|
|||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||
import 'package:contacts_plus_plus/models/session.dart';
|
||||
|
||||
class SessionApi {
|
||||
static Future<List<Session>> getSessions(ApiClient client, {DateTime? updatedSince, bool includeEnded = false,
|
||||
String name = "", String hostName = "", String hostId = "", int minActiveUsers = 0, bool includeEmptyHeadless = true,
|
||||
}) async {
|
||||
final query = "?includeEnded=$includeEnded"
|
||||
"&includeEmptyHeadless=$includeEmptyHeadless"
|
||||
"&minActiveUsers=$minActiveUsers"
|
||||
"${updatedSince == null ? "" : "&updatedSince=${updatedSince.toIso8601String()}"}"
|
||||
"${name.isEmpty ? "" : "&name=$name"}"
|
||||
"${hostName.isEmpty ? "" : "&hostName=$hostName"}"
|
||||
"${hostId.isEmpty ? "" : "&hostId=$hostId"}";
|
||||
final response = await client.get("/sessions$query");
|
||||
ApiClient.checkResponse(response);
|
||||
final body = jsonDecode(response.body) as List;
|
||||
return body.map((e) => Session.fromMap(e)).toList();
|
||||
}
|
||||
}
|
|
@ -29,8 +29,8 @@ class UserApi {
|
|||
return UserStatus.fromMap(data);
|
||||
}
|
||||
|
||||
static Future<void> notifyOnlineInstance(ApiClient client) async {
|
||||
final response = await client.post("/stats/instanceOnline/${client.authenticationData.secretMachineId.hashCode}");
|
||||
static Future<void> notifyOnlineInstance(ApiClient client, {required String machineId}) async {
|
||||
final response = await client.post("/stats/instanceOnline/$machineId");
|
||||
ApiClient.checkResponse(response);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:contacts_plus_plus/config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:html/parser.dart' as htmlparser;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
enum NeosDBEndpoint
|
||||
{
|
||||
|
@ -10,39 +14,6 @@ enum NeosDBEndpoint
|
|||
videoCDN,
|
||||
}
|
||||
|
||||
extension NeosStringExtensions on Uri {
|
||||
static String dbSignature(Uri neosdb) => neosdb.pathSegments.length < 2 ? "" : p.basenameWithoutExtension(neosdb.pathSegments[1]);
|
||||
static String? neosDBQuery(Uri neosdb) => neosdb.query.trim().isEmpty ? null : neosdb.query.substring(1);
|
||||
static bool isLegacyNeosDB(Uri uri) => !(uri.scheme != "neosdb") && uri.pathSegments.length >= 2 && p.basenameWithoutExtension(uri.pathSegments[1]).length < 30;
|
||||
|
||||
Uri neosDBToHTTP(NeosDBEndpoint endpoint) {
|
||||
var signature = dbSignature(this);
|
||||
var query = neosDBQuery(this);
|
||||
if (query != null) {
|
||||
signature = "$signature/$query";
|
||||
}
|
||||
if (isLegacyNeosDB(this)) {
|
||||
return Uri.parse(Config.legacyCloudUrl + signature);
|
||||
}
|
||||
String base;
|
||||
switch (endpoint) {
|
||||
case NeosDBEndpoint.blob:
|
||||
base = Config.blobStorageUrl;
|
||||
break;
|
||||
case NeosDBEndpoint.cdn:
|
||||
base = Config.neosCdnUrl;
|
||||
break;
|
||||
case NeosDBEndpoint.videoCDN:
|
||||
base = Config.videoStorageUrl;
|
||||
break;
|
||||
case NeosDBEndpoint.def:
|
||||
base = Config.neosAssetsUrl;
|
||||
}
|
||||
|
||||
return Uri.parse(base + signature);
|
||||
}
|
||||
}
|
||||
|
||||
class Aux {
|
||||
static String neosDbToHttp(String? neosdb) {
|
||||
if (neosdb == null || neosdb.isEmpty) return "";
|
||||
|
@ -55,6 +26,13 @@ class Aux {
|
|||
}
|
||||
return fullUri;
|
||||
}
|
||||
|
||||
static String toURLBase64(Uint8List data) => base64.encode(data)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "");
|
||||
|
||||
static String generateMachineId() => Aux.toURLBase64((const Uuid().v1obj().toBytes())).toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
|
@ -67,15 +45,41 @@ extension Unique<E, Id> on List<E> {
|
|||
}
|
||||
}
|
||||
|
||||
extension Strip on String {
|
||||
extension StringX on String {
|
||||
|
||||
String stripHtml() {
|
||||
final document = htmlparser.parse(this);
|
||||
return htmlparser.parse(document.body?.text).documentElement?.text ?? "";
|
||||
return htmlparser
|
||||
.parse(document.body?.text)
|
||||
.documentElement
|
||||
?.text ?? "";
|
||||
}
|
||||
|
||||
// This won't be accurate since userIds can't contain certain characters that usernames can
|
||||
// but it's fine for just having a name to display
|
||||
String stripUid() => startsWith("U-") ? substring(2) : this;
|
||||
|
||||
String get overflow =>
|
||||
Characters(this)
|
||||
.replaceAll(Characters(''), Characters('\u{200B}'))
|
||||
.toString();
|
||||
|
||||
bool looseMatch(String other) {
|
||||
if (other.isEmpty) return true;
|
||||
var index = 0;
|
||||
for (final needleChar in other.characters) {
|
||||
if (index >= characters.length) return false;
|
||||
for (; index < characters.length; index++) {
|
||||
if (needleChar.toLowerCase() == characters.elementAt(index).toLowerCase()) break;
|
||||
}
|
||||
}
|
||||
if (index < characters.length) {
|
||||
return true;
|
||||
} else if (characters.last.toLowerCase() == other.characters.last.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
extension Format on Duration {
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'package:flutter_phoenix/flutter_phoenix.dart';
|
|||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:contacts_plus_plus/models/authentication_data.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../config.dart';
|
||||
|
@ -13,13 +14,14 @@ import '../config.dart';
|
|||
class ApiClient {
|
||||
static const String totpKey = "TOTP";
|
||||
static const String userIdKey = "userId";
|
||||
static const String machineIdKey = "machineId";
|
||||
static const String secretMachineIdKey = "machineId";
|
||||
static const String tokenKey = "token";
|
||||
static const String passwordKey = "password";
|
||||
|
||||
ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData;
|
||||
|
||||
final AuthenticationData _authenticationData;
|
||||
final Logger _logger = Logger("ApiClient");
|
||||
|
||||
AuthenticationData get authenticationData => _authenticationData;
|
||||
String get userId => _authenticationData.userId;
|
||||
|
@ -33,7 +35,7 @@ class ApiClient {
|
|||
String? oneTimePad,
|
||||
}) async {
|
||||
final body = {
|
||||
"username": username,
|
||||
(username.contains("@") ? "email" : "username"): username.trim(),
|
||||
"password": password,
|
||||
"rememberMe": rememberMe,
|
||||
"secretMachineId": const Uuid().v4(),
|
||||
|
@ -58,7 +60,7 @@ class ApiClient {
|
|||
if (authData.isAuthenticated) {
|
||||
const FlutterSecureStorage storage = FlutterSecureStorage();
|
||||
await storage.write(key: userIdKey, value: authData.userId);
|
||||
await storage.write(key: machineIdKey, value: authData.secretMachineId);
|
||||
await storage.write(key: secretMachineIdKey, value: authData.secretMachineId);
|
||||
await storage.write(key: tokenKey, value: authData.token);
|
||||
if (rememberPass) await storage.write(key: passwordKey, value: password);
|
||||
}
|
||||
|
@ -68,7 +70,7 @@ class ApiClient {
|
|||
static Future<AuthenticationData> tryCachedLogin() async {
|
||||
const FlutterSecureStorage storage = FlutterSecureStorage();
|
||||
String? userId = await storage.read(key: userIdKey);
|
||||
String? machineId = await storage.read(key: machineIdKey);
|
||||
String? machineId = await storage.read(key: secretMachineIdKey);
|
||||
String? token = await storage.read(key: tokenKey);
|
||||
String? password = await storage.read(key: passwordKey);
|
||||
|
||||
|
@ -97,10 +99,17 @@ class ApiClient {
|
|||
return AuthenticationData.unauthenticated();
|
||||
}
|
||||
|
||||
Future<void> extendSession() async {
|
||||
final response = await patch("/userSessions");
|
||||
if (response.statusCode != 204) {
|
||||
throw "Failed to extend session.";
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout(BuildContext context) async {
|
||||
const FlutterSecureStorage storage = FlutterSecureStorage();
|
||||
await storage.delete(key: userIdKey);
|
||||
await storage.delete(key: machineIdKey);
|
||||
await storage.delete(key: secretMachineIdKey);
|
||||
await storage.delete(key: tokenKey);
|
||||
await storage.delete(key: passwordKey);
|
||||
if (context.mounted) {
|
||||
|
@ -117,7 +126,8 @@ class ApiClient {
|
|||
// TODO: Show the login screen again if cached login was unsuccessful.
|
||||
throw "You are not authorized to do that.";
|
||||
}
|
||||
if (response.statusCode != 200) {
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode > 299) {
|
||||
throw "Unknown Error${kDebugMode ? ": ${response.statusCode}|${response.body}" : ""}";
|
||||
}
|
||||
}
|
||||
|
@ -129,6 +139,7 @@ class ApiClient {
|
|||
Future<http.Response> get(String path, {Map<String, String>? headers}) {
|
||||
headers ??= {};
|
||||
headers.addAll(authorizationHeader);
|
||||
_logger.info("GET: $path");
|
||||
return http.get(buildFullUri(path), headers: headers);
|
||||
}
|
||||
|
||||
|
@ -136,6 +147,7 @@ class ApiClient {
|
|||
headers ??= {};
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers.addAll(authorizationHeader);
|
||||
_logger.info("PST: $path");
|
||||
return http.post(buildFullUri(path), headers: headers, body: body);
|
||||
}
|
||||
|
||||
|
@ -143,12 +155,21 @@ class ApiClient {
|
|||
headers ??= {};
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers.addAll(authorizationHeader);
|
||||
_logger.info("PUT: $path");
|
||||
return http.put(buildFullUri(path), headers: headers, body: body);
|
||||
}
|
||||
|
||||
Future<http.Response> delete(String path, {Map<String, String>? headers}) {
|
||||
headers ??= {};
|
||||
headers.addAll(authorizationHeader);
|
||||
_logger.info("DEL: $path");
|
||||
return http.delete(buildFullUri(path), headers: headers);
|
||||
}
|
||||
|
||||
Future<http.Response> patch(String path, {Map<String, String>? headers}) {
|
||||
headers ??= {};
|
||||
headers.addAll(authorizationHeader);
|
||||
_logger.info("PAT: $path");
|
||||
return http.patch(buildFullUri(path), headers: headers);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,16 +4,22 @@ import 'dart:io';
|
|||
import 'package:contacts_plus_plus/apis/friend_api.dart';
|
||||
import 'package:contacts_plus_plus/apis/message_api.dart';
|
||||
import 'package:contacts_plus_plus/apis/user_api.dart';
|
||||
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||
import 'package:contacts_plus_plus/clients/notification_client.dart';
|
||||
import 'package:contacts_plus_plus/clients/settings_client.dart';
|
||||
import 'package:contacts_plus_plus/models/authentication_data.dart';
|
||||
import 'package:contacts_plus_plus/models/friend.dart';
|
||||
import 'package:contacts_plus_plus/models/settings.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||
import 'package:contacts_plus_plus/config.dart';
|
||||
import 'package:contacts_plus_plus/models/message.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
enum EventType {
|
||||
|
@ -45,9 +51,16 @@ class MessagingClient extends ChangeNotifier {
|
|||
static const String eofChar = "";
|
||||
static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$eofChar";
|
||||
static const List<int> _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60];
|
||||
static const String taskName = "periodic-unread-check";
|
||||
|
||||
static const int _unreadCheckMinuteInterval = 30;
|
||||
static const String unreadCheckTaskName = "periodic-unread-check";
|
||||
static const String _storageNotifiedUnreadsKey = "notfiedUnreads";
|
||||
static const String _storageLastUpdateKey = "lastUnreadCheck";
|
||||
static const String _hiveKey = "mClient";
|
||||
static const String _storedFriendsKey = "friends";
|
||||
static const FlutterSecureStorage _storage = FlutterSecureStorage();
|
||||
static const Duration _autoRefreshDuration = Duration(seconds: 90);
|
||||
static const Duration _refreshTimeoutDuration = Duration(seconds: 30);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
final Map<String, Friend> _friendsCache = {};
|
||||
final List<Friend> _sortedFriendsCache = []; // Keep a sorted copy so as to not have to sort during build()
|
||||
|
@ -55,13 +68,16 @@ class MessagingClient extends ChangeNotifier {
|
|||
final Map<String, List<Message>> _unreads = {};
|
||||
final Logger _logger = Logger("NeosHub");
|
||||
final Workmanager _workmanager = Workmanager();
|
||||
|
||||
final NotificationClient _notificationClient;
|
||||
final SettingsClient _settingsClient;
|
||||
|
||||
WebSocket? _wsChannel;
|
||||
Friend? selectedFriend;
|
||||
Timer? _notifyOnlineTimer;
|
||||
Timer? _autoRefresh;
|
||||
Timer? _refreshTimeout;
|
||||
int _attempts = 0;
|
||||
WebSocket? _wsChannel;
|
||||
bool _isConnecting = false;
|
||||
String? _initStatus;
|
||||
|
||||
|
@ -69,15 +85,97 @@ class MessagingClient extends ChangeNotifier {
|
|||
|
||||
bool get websocketConnected => _wsChannel != null;
|
||||
|
||||
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient})
|
||||
: _apiClient = apiClient, _notificationClient = notificationClient {
|
||||
refreshFriendsListWithErrorHandler();
|
||||
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient,
|
||||
required SettingsClient settingsClient})
|
||||
: _apiClient = apiClient, _notificationClient = notificationClient, _settingsClient = settingsClient {
|
||||
initFriends();
|
||||
startWebsocket();
|
||||
_notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async {
|
||||
// We should probably let the MessagingClient handle the entire state of USerStatus instead of mirroring like this
|
||||
// but I don't feel like implementing that right now.
|
||||
UserApi.setStatus(apiClient, status: await UserApi.getUserStatus(apiClient, userId: apiClient.userId));
|
||||
});
|
||||
//_settingsClient.addListener(onSettingsChanged);
|
||||
if (!_settingsClient.currentSettings.notificationsDenied.valueOrDefault) {
|
||||
//registerNotificationTask();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initFriends() async {
|
||||
try {
|
||||
await initBox();
|
||||
await _restoreFriendsList();
|
||||
try {
|
||||
await refreshFriendsList();
|
||||
} catch (e, s) {
|
||||
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e,s) {
|
||||
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
|
||||
refreshFriendsListWithErrorHandler();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onSettingsChanged(Settings oldSettings, Settings newSettings) async {
|
||||
if (oldSettings.notificationsDenied.valueOrDefault != newSettings.notificationsDenied.valueOrDefault) {
|
||||
if (newSettings.notificationsDenied.valueOrDefault) {
|
||||
await unregisterNotificationTask();
|
||||
} else {
|
||||
await registerNotificationTask();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<Message>> updateNotified(List<Message> unreads) async {
|
||||
if (unreads.isEmpty) return [];
|
||||
const storage = FlutterSecureStorage();
|
||||
final data = await storage.read(key: _storageNotifiedUnreadsKey);
|
||||
|
||||
final existing = data == null ? <String>[] : (jsonDecode(data) as List).map((e) => "$e").toList();
|
||||
final unnotified = unreads.where((unread) => !existing.contains(unread.id));
|
||||
existing.addAll(unnotified.map((e) => e.id));
|
||||
await storage.write(key: _storageNotifiedUnreadsKey, value: jsonEncode(existing.unique()));
|
||||
return unnotified.toList();
|
||||
}
|
||||
|
||||
static Future<void> backgroundCheckUnreads(Map<String, dynamic>? inputData) async {
|
||||
if (inputData == null) throw "Unauthenticated";
|
||||
return;
|
||||
final auth = AuthenticationData.fromMap(inputData);
|
||||
const storage = FlutterSecureStorage();
|
||||
final lastCheckData = await storage.read(key: _storageLastUpdateKey);
|
||||
if (lastCheckData != null && DateTime.now().difference(DateTime.parse(lastCheckData)) < const Duration(
|
||||
minutes: _unreadCheckMinuteInterval,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final client = ApiClient(authenticationData: auth);
|
||||
await client.extendSession();
|
||||
|
||||
final unreads = await MessageApi.getUserMessages(client, unreadOnly: true);
|
||||
|
||||
final unnotified = await updateNotified(unreads);
|
||||
|
||||
await NotificationClient().showUnreadMessagesNotification(unnotified);
|
||||
await storage.write(key: _storageLastUpdateKey, value: DateTime.now().toIso8601String());
|
||||
}
|
||||
|
||||
Future<void> registerNotificationTask() async {
|
||||
final auth = _apiClient.authenticationData;
|
||||
if (!auth.isAuthenticated) throw "Unauthenticated";
|
||||
_workmanager.registerPeriodicTask(
|
||||
unreadCheckTaskName,
|
||||
unreadCheckTaskName,
|
||||
frequency: const Duration(minutes: _unreadCheckMinuteInterval),
|
||||
inputData: auth.toMap(),
|
||||
existingWorkPolicy: ExistingWorkPolicy.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> unregisterNotificationTask() async {
|
||||
await _workmanager.cancelByUniqueName(unreadCheckTaskName);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -108,27 +206,65 @@ class MessagingClient extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> initBox() async {
|
||||
try {
|
||||
final path = await getTemporaryDirectory();
|
||||
Hive.init(path.path);
|
||||
await Hive.openBox(_hiveKey, path: path.path);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _restoreFriendsList() async {
|
||||
if (!Hive.isBoxOpen(_hiveKey)) throw "Failed to open box";
|
||||
final mStorage = Hive.box(_hiveKey);
|
||||
final storedFriends = await mStorage.get(_storedFriendsKey) as List?;
|
||||
if (storedFriends == null) throw "No cached friends list";
|
||||
_friendsCache.clear();
|
||||
_sortedFriendsCache.clear();
|
||||
|
||||
for (final storedFriend in storedFriends) {
|
||||
final friend = Friend.fromMap(storedFriend);
|
||||
_friendsCache[friend.id] = friend;
|
||||
_sortedFriendsCache.add(friend);
|
||||
}
|
||||
_sortFriendsCache();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
Future<void> refreshFriendsList() async {
|
||||
if (_refreshTimeout?.isActive == true) return;
|
||||
|
||||
_autoRefresh?.cancel();
|
||||
_autoRefresh = Timer(_autoRefreshDuration, () => refreshFriendsList());
|
||||
_refreshTimeout?.cancel();
|
||||
_refreshTimeout = Timer(_refreshTimeoutDuration, () {});
|
||||
|
||||
final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true);
|
||||
updateAllUnreads(unreadMessages.toList());
|
||||
_autoRefresh = Timer(_autoRefreshDuration, () async {
|
||||
try {
|
||||
await refreshFriendsList();
|
||||
} catch (_) {
|
||||
// We don't really need to do anything if fetching unreads and messages fails in the background since we can
|
||||
// just keep showing the old state until refreshing succeeds.
|
||||
}
|
||||
});
|
||||
final now = DateTime.now();
|
||||
final lastUpdate = await _storage.read(key: _storageLastUpdateKey);
|
||||
if (lastUpdate != null && now.difference(DateTime.parse(lastUpdate)) < _autoRefreshDuration) throw "You are being rate limited.";
|
||||
|
||||
final friends = await FriendApi.getFriendsList(_apiClient);
|
||||
final List<Map> storableFriends = [];
|
||||
_friendsCache.clear();
|
||||
for (final friend in friends) {
|
||||
_friendsCache[friend.id] = friend;
|
||||
storableFriends.add(friend.toMap(shallow: true));
|
||||
}
|
||||
_sortedFriendsCache.clear();
|
||||
_sortedFriendsCache.addAll(friends);
|
||||
_sortFriendsCache();
|
||||
_initStatus = "";
|
||||
await _storage.write(key: _storageLastUpdateKey, value: now.toIso8601String());
|
||||
final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true);
|
||||
updateAllUnreads(unreadMessages.toList());
|
||||
|
||||
notifyListeners();
|
||||
if (!Hive.isBoxOpen(_hiveKey)) return;
|
||||
final mStorage = Hive.box(_hiveKey);
|
||||
mStorage.put(_storedFriendsKey, storableFriends);
|
||||
}
|
||||
|
||||
void _sortFriendsCache() {
|
||||
|
@ -164,9 +300,13 @@ class MessagingClient extends ChangeNotifier {
|
|||
} else {
|
||||
messages.add(message);
|
||||
}
|
||||
messages.sort();
|
||||
_sortFriendsCache();
|
||||
_notificationClient.showUnreadMessagesNotification(messages.reversed);
|
||||
if (!_settingsClient.currentSettings.notificationsDenied.valueOrDefault) {
|
||||
updateNotified(messages).then((unnotified) {
|
||||
unnotified.sort();
|
||||
_notificationClient.showUnreadMessagesNotification(unnotified.reversed);
|
||||
});
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
@ -217,27 +357,6 @@ class MessagingClient extends ChangeNotifier {
|
|||
|
||||
MessageCache? getUserMessageCache(String userId) => _messageCache[userId];
|
||||
|
||||
static Future<void> backgroundCheckUnreads(Map<String, dynamic>? inputData) async {
|
||||
if (inputData == null) return;
|
||||
final auth = AuthenticationData.fromMap(inputData);
|
||||
final unreads = await MessageApi.getUserMessages(ApiClient(authenticationData: auth), unreadOnly: true);
|
||||
for (var message in unreads) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateNotificationTask(int minuteInterval) async {
|
||||
final auth = _apiClient.authenticationData;
|
||||
if (!auth.isAuthenticated) throw "Unauthenticated";
|
||||
await _workmanager.cancelByUniqueName(taskName);
|
||||
_workmanager.registerPeriodicTask(
|
||||
taskName,
|
||||
taskName,
|
||||
frequency: Duration(minutes: minuteInterval),
|
||||
inputData: auth.toMap(),
|
||||
);
|
||||
}
|
||||
|
||||
void _onDisconnected(error) async {
|
||||
_wsChannel = null;
|
||||
_logger.warning("Neos Hub connection died with error '$error', reconnecting...");
|
||||
|
@ -388,5 +507,10 @@ class MessagingClient extends ChangeNotifier {
|
|||
};
|
||||
_sendData(data);
|
||||
clearUnreadsForUser(batch.senderId);
|
||||
_storage.read(key: _storageNotifiedUnreadsKey).then((data) async {
|
||||
final existing = data == null ? [] : jsonDecode(data) as List<String>;
|
||||
final marked = existing.where((element) => !batch.ids.contains(element)).toList();
|
||||
await _storage.write(key: _storageNotifiedUnreadsKey, value: jsonEncode(marked));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -39,13 +39,15 @@ class NotificationClient {
|
|||
uname.hashCode,
|
||||
null,
|
||||
null,
|
||||
fln.NotificationDetails(android: fln.AndroidNotificationDetails(
|
||||
fln.NotificationDetails(
|
||||
android: fln.AndroidNotificationDetails(
|
||||
_messageChannel.id,
|
||||
_messageChannel.name,
|
||||
channelDescription: _messageChannel.description,
|
||||
importance: fln.Importance.high,
|
||||
priority: fln.Priority.max,
|
||||
actions: [], //TODO: Make clicking message notification open chat of specified user.
|
||||
actions: [],
|
||||
//TODO: Make clicking message notification open chat of specified user.
|
||||
styleInformation: fln.MessagingStyleInformation(
|
||||
fln.Person(
|
||||
name: uname,
|
||||
|
|
|
@ -7,19 +7,30 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||
class SettingsClient {
|
||||
static const String _settingsKey = "settings";
|
||||
static const _storage = FlutterSecureStorage();
|
||||
final List<Future<void> Function(Settings oldSettings, Settings newSettings)> _listeners = [];
|
||||
Settings _currentSettings = Settings();
|
||||
|
||||
void addListener(Future<void> Function(Settings oldSettings, Settings newSettings) listener) {
|
||||
_listeners.add(listener);
|
||||
}
|
||||
|
||||
Future<void> notifyListeners(Settings oldSettings, Settings newSettings) async {
|
||||
for(final listener in _listeners) {
|
||||
await listener.call(oldSettings, newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
Settings get currentSettings => _currentSettings;
|
||||
|
||||
Future<void> loadSettings() async {
|
||||
final data = await _storage.read(key: _settingsKey);
|
||||
if (data == null) return;
|
||||
_currentSettings = Settings.fromMap(jsonDecode(data));
|
||||
|
||||
}
|
||||
|
||||
Future<void> changeSettings(Settings newSettings) async {
|
||||
_currentSettings = newSettings;
|
||||
await _storage.write(key: _settingsKey, value: jsonEncode(newSettings.toMap()));
|
||||
await notifyListeners(_currentSettings, newSettings);
|
||||
_currentSettings = newSettings;
|
||||
}
|
||||
}
|
|
@ -6,14 +6,13 @@ import 'package:contacts_plus_plus/client_holder.dart';
|
|||
import 'package:contacts_plus_plus/clients/messaging_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/home.dart';
|
||||
import 'package:contacts_plus_plus/widgets/login_screen.dart';
|
||||
import 'package:contacts_plus_plus/widgets/update_notifier.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'models/authentication_data.dart';
|
||||
|
||||
|
@ -22,22 +21,36 @@ void main() async {
|
|||
if (Platform.isAndroid) {
|
||||
await Workmanager().initialize(
|
||||
callbackDispatcher, // The top level function, aka callbackDispatcher
|
||||
isInDebugMode: true // If enabled it will post a notification whenever the task is running. Handy for debugging tasks
|
||||
isInDebugMode: false, // If enabled it will post a notification whenever the task is running. Handy for debugging tasks
|
||||
);
|
||||
}
|
||||
|
||||
Logger.root.onRecord.listen((event) => log(event.message, name: event.loggerName, time: event.time));
|
||||
final settingsClient = SettingsClient();
|
||||
await settingsClient.loadSettings();
|
||||
if (settingsClient.currentSettings.publicMachineId.value == null) {
|
||||
// If no machineId is set, write the generated one to disk
|
||||
settingsClient.changeSettings(
|
||||
settingsClient.currentSettings.copyWith(
|
||||
publicMachineId: settingsClient.currentSettings.publicMachineId.valueOrDefault,
|
||||
),
|
||||
);
|
||||
}
|
||||
runApp(Phoenix(child: ContactsPlusPlus(settingsClient: settingsClient,)));
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point') // Mandatory if the App is obfuscated or using Flutter 3.1+
|
||||
void callbackDispatcher() {
|
||||
return;
|
||||
Workmanager().executeTask((String task, Map<String, dynamic>? inputData) async {
|
||||
debugPrint("Native called background task: $task"); //simpleTask will be emitted here.
|
||||
if (task == MessagingClient.taskName) {
|
||||
final unreads = MessagingClient.backgroundCheckUnreads(inputData);
|
||||
if (task == MessagingClient.unreadCheckTaskName) {
|
||||
try {
|
||||
await MessagingClient.backgroundCheckUnreads(inputData);
|
||||
} catch (e) {
|
||||
Logger("Workman").severe(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
return Future.value(true);
|
||||
});
|
||||
|
@ -122,17 +135,10 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
|||
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder
|
||||
builder: (context) {
|
||||
showUpdateDialogOnFirstBuild(context);
|
||||
final clientHolder = ClientHolder.of(context);
|
||||
return _authData.isAuthenticated ?
|
||||
ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
|
||||
create: (context) =>
|
||||
MessagingClient(
|
||||
apiClient: clientHolder.apiClient,
|
||||
notificationClient: clientHolder.notificationClient,
|
||||
),
|
||||
child: const FriendsList(),
|
||||
) :
|
||||
LoginScreen(
|
||||
if (_authData.isAuthenticated) {
|
||||
return const Home();
|
||||
} else {
|
||||
return LoginScreen(
|
||||
onLoginSuccessful: (AuthenticationData authData) async {
|
||||
if (authData.isAuthenticated) {
|
||||
setState(() {
|
||||
|
@ -142,6 +148,7 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
|||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
);
|
||||
|
|
34
lib/models/asset/asset_diff.dart
Normal file
34
lib/models/asset/asset_diff.dart
Normal file
|
@ -0,0 +1,34 @@
|
|||
|
||||
class AssetDiff {
|
||||
final String hash;
|
||||
final int bytes;
|
||||
final Diff state;
|
||||
final bool isUploaded;
|
||||
|
||||
const AssetDiff({required this.hash, required this.bytes, required this.state, required this.isUploaded});
|
||||
|
||||
factory AssetDiff.fromMap(Map map) {
|
||||
return AssetDiff(
|
||||
hash: map["hash"],
|
||||
bytes: map["bytes"],
|
||||
state: Diff.fromInt(map["state"]),
|
||||
isUploaded: map["isUploaded"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum Diff {
|
||||
added,
|
||||
unchanged,
|
||||
removed;
|
||||
|
||||
factory Diff.fromInt(int? idx) {
|
||||
return Diff.values[idx ?? 1];
|
||||
}
|
||||
|
||||
factory Diff.fromString(String? text) {
|
||||
return Diff.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
|
||||
orElse: () => Diff.unchanged,
|
||||
);
|
||||
}
|
||||
}
|
46
lib/models/asset/asset_upload_data.dart
Normal file
46
lib/models/asset/asset_upload_data.dart
Normal file
|
@ -0,0 +1,46 @@
|
|||
|
||||
enum UploadState {
|
||||
uploadingChunks,
|
||||
finalizing,
|
||||
uploaded,
|
||||
failed,
|
||||
unknown;
|
||||
|
||||
factory UploadState.fromString(String? text) {
|
||||
return UploadState.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
|
||||
orElse: () => UploadState.unknown,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssetUploadData {
|
||||
final String signature;
|
||||
final String variant;
|
||||
final String ownerId;
|
||||
final int totalBytes;
|
||||
final int chunkSize;
|
||||
final int totalChunks;
|
||||
final UploadState uploadState;
|
||||
|
||||
const AssetUploadData({
|
||||
required this.signature,
|
||||
required this.variant,
|
||||
required this.ownerId,
|
||||
required this.totalBytes,
|
||||
required this.chunkSize,
|
||||
required this.totalChunks,
|
||||
required this.uploadState,
|
||||
});
|
||||
|
||||
factory AssetUploadData.fromMap(Map map) {
|
||||
return AssetUploadData(
|
||||
signature: map["signature"],
|
||||
variant: map["variant"] ?? "",
|
||||
ownerId: map["ownerId"] ?? "",
|
||||
totalBytes: map["totalBytes"] ?? -1,
|
||||
chunkSize: map["chunkSize"] ?? -1,
|
||||
totalChunks: map["totalChunks"] ?? -1,
|
||||
uploadState: UploadState.fromString(map["uploadStat"]),
|
||||
);
|
||||
}
|
||||
}
|
26
lib/models/asset/neos_db_asset.dart
Normal file
26
lib/models/asset/neos_db_asset.dart
Normal file
|
@ -0,0 +1,26 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
class NeosDBAsset {
|
||||
final String hash;
|
||||
final int bytes;
|
||||
|
||||
const NeosDBAsset({required this.hash, required this.bytes});
|
||||
|
||||
factory NeosDBAsset.fromMap(Map map) {
|
||||
return NeosDBAsset(hash: map["hash"] ?? "", bytes: map["bytes"] ?? -1);
|
||||
}
|
||||
|
||||
factory NeosDBAsset.fromData(Uint8List data) {
|
||||
final digest = sha256.convert(data);
|
||||
return NeosDBAsset(hash: digest.toString().replaceAll("-", "").toLowerCase(), bytes: data.length);
|
||||
}
|
||||
|
||||
Map toMap() {
|
||||
return {
|
||||
"hash": hash,
|
||||
"bytes": bytes,
|
||||
};
|
||||
}
|
||||
}
|
41
lib/models/asset/preprocess_status.dart
Normal file
41
lib/models/asset/preprocess_status.dart
Normal file
|
@ -0,0 +1,41 @@
|
|||
import 'package:contacts_plus_plus/models/asset/asset_diff.dart';
|
||||
|
||||
enum RecordPreprocessState
|
||||
{
|
||||
preprocessing,
|
||||
success,
|
||||
failed;
|
||||
|
||||
factory RecordPreprocessState.fromString(String? text) {
|
||||
return RecordPreprocessState.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
|
||||
orElse: () => RecordPreprocessState.failed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PreprocessStatus {
|
||||
final String id;
|
||||
final String ownerId;
|
||||
final String recordId;
|
||||
final RecordPreprocessState state;
|
||||
final num progress;
|
||||
final String failReason;
|
||||
final List<AssetDiff> resultDiffs;
|
||||
|
||||
const PreprocessStatus({required this.id, required this.ownerId, required this.recordId, required this.state,
|
||||
required this.progress, required this.failReason, required this.resultDiffs,
|
||||
});
|
||||
|
||||
factory PreprocessStatus.fromMap(Map map) {
|
||||
return PreprocessStatus(
|
||||
id: map["id"],
|
||||
ownerId: map["ownerId"],
|
||||
recordId: map["recordId"],
|
||||
state: RecordPreprocessState.fromString(map["state"]),
|
||||
progress: map["progress"],
|
||||
failReason: map["failReason"] ?? "",
|
||||
resultDiffs: (map["resultDiffs"] as List? ?? []).map((e) => AssetDiff.fromMap(e)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
162
lib/models/asset/record.dart
Normal file
162
lib/models/asset/record.dart
Normal file
|
@ -0,0 +1,162 @@
|
|||
import 'package:contacts_plus_plus/models/asset/neos_db_asset.dart';
|
||||
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
enum RecordType {
|
||||
unknown,
|
||||
link,
|
||||
object,
|
||||
directory,
|
||||
texture,
|
||||
audio;
|
||||
|
||||
factory RecordType.fromName(String? name) {
|
||||
return RecordType.values.firstWhere((element) => element.name.toLowerCase() == name?.toLowerCase().trim(), orElse: () => RecordType.unknown);
|
||||
}
|
||||
}
|
||||
|
||||
class Record {
|
||||
final String id;
|
||||
final String ownerId;
|
||||
final String? assetUri;
|
||||
final int globalVersion;
|
||||
final int localVersion;
|
||||
final String name;
|
||||
final TextSpan? formattedName;
|
||||
final String? description;
|
||||
final List<String>? tags;
|
||||
final String? thumbnailUri;
|
||||
final bool isPublic;
|
||||
final bool isForPatreons;
|
||||
final bool isListed;
|
||||
final bool isDeleted;
|
||||
final DateTime? lastModificationTime;
|
||||
final List<NeosDBAsset> neosDBManifest;
|
||||
final String lastModifyingUserId;
|
||||
final String lastModifyingMachineId;
|
||||
final DateTime? creationTime;
|
||||
final RecordType recordType;
|
||||
|
||||
const Record({
|
||||
required this.id,
|
||||
this.formattedName,
|
||||
required this.ownerId,
|
||||
this.assetUri,
|
||||
this.globalVersion=0,
|
||||
this.localVersion=0,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.tags,
|
||||
required this.recordType,
|
||||
this.thumbnailUri,
|
||||
required this.isPublic,
|
||||
required this.isListed,
|
||||
required this.isDeleted,
|
||||
required this.isForPatreons,
|
||||
this.lastModificationTime,
|
||||
required this.neosDBManifest,
|
||||
required this.lastModifyingUserId,
|
||||
required this.lastModifyingMachineId,
|
||||
this.creationTime,
|
||||
});
|
||||
|
||||
factory Record.fromMap(Map map) {
|
||||
return Record(
|
||||
id: map["id"],
|
||||
ownerId: map["ownerId"],
|
||||
assetUri: map["assetUri"],
|
||||
globalVersion: map["globalVersion"] ?? 0,
|
||||
localVersion: map["localVersion"] ?? 0,
|
||||
name: map["name"] ?? "",
|
||||
formattedName: StringFormatter.tryFormat(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,
|
||||
isDeleted: map["isDeleted"] ?? false,
|
||||
lastModificationTime: DateTime.tryParse(map["lastModificationTime"]),
|
||||
neosDBManifest: (map["neosDBManifest"] as List? ?? []).map((e) => NeosDBAsset.fromMap(e)).toList(),
|
||||
lastModifyingUserId: map["lastModifyingUserId"] ?? "",
|
||||
lastModifyingMachineId: map["lastModifyingMachineId"] ?? "",
|
||||
creationTime: DateTime.tryParse(map["lastModificationTime"]),
|
||||
);
|
||||
}
|
||||
|
||||
Record copyWith({
|
||||
String? id,
|
||||
String? ownerId,
|
||||
String? assetUri,
|
||||
int? globalVersion,
|
||||
int? localVersion,
|
||||
String? name,
|
||||
TextSpan? formattedName,
|
||||
String? description,
|
||||
List<String>? tags,
|
||||
RecordType? recordType,
|
||||
String? thumbnailUri,
|
||||
bool? isPublic,
|
||||
bool? isForPatreons,
|
||||
bool? isListed,
|
||||
bool? isDeleted,
|
||||
DateTime? lastModificationTime,
|
||||
List<NeosDBAsset>? neosDBManifest,
|
||||
String? lastModifyingUserId,
|
||||
String? lastModifyingMachineId,
|
||||
DateTime? creationTime,
|
||||
}) {
|
||||
return Record(
|
||||
id: id ?? this.id,
|
||||
ownerId: ownerId ?? this.ownerId,
|
||||
assetUri: assetUri ?? this.assetUri,
|
||||
globalVersion: globalVersion ?? this.globalVersion,
|
||||
localVersion: localVersion ?? this.localVersion,
|
||||
name: name ?? this.name,
|
||||
formattedName: formattedName ?? this.formattedName,
|
||||
description: description ?? this.description,
|
||||
tags: tags ?? this.tags,
|
||||
recordType: recordType ?? this.recordType,
|
||||
thumbnailUri: thumbnailUri ?? this.thumbnailUri,
|
||||
isPublic: isPublic ?? this.isPublic,
|
||||
isForPatreons: isForPatreons ?? this.isForPatreons,
|
||||
isListed: isListed ?? this.isListed,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
lastModificationTime: lastModificationTime ?? this.lastModificationTime,
|
||||
neosDBManifest: neosDBManifest ?? this.neosDBManifest,
|
||||
lastModifyingUserId: lastModifyingUserId ?? this.lastModifyingUserId,
|
||||
lastModifyingMachineId: lastModifyingMachineId ?? this.lastModifyingMachineId,
|
||||
creationTime: creationTime ?? this.creationTime,
|
||||
);
|
||||
}
|
||||
|
||||
Map toMap() {
|
||||
return {
|
||||
"id": id,
|
||||
"ownerId": ownerId,
|
||||
"assetUri": assetUri,
|
||||
"globalVersion": globalVersion,
|
||||
"localVersion": localVersion,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"recordType": recordType.name,
|
||||
"thumbnailUri": thumbnailUri,
|
||||
"isPublic": isPublic,
|
||||
"isForPatreons": isForPatreons,
|
||||
"isListed": isListed,
|
||||
"isDeleted": isDeleted,
|
||||
"lastModificationTime": lastModificationTime?.toIso8601String(),
|
||||
"neosDBManifest": neosDBManifest.map((e) => e.toMap()).toList(),
|
||||
"lastModifyingUserId": lastModifyingUserId,
|
||||
"lastModifyingMachineId": lastModifyingMachineId,
|
||||
"creationTime": creationTime?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
static String generateId() {
|
||||
return "R-${const Uuid().v4()}";
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ import 'package:contacts_plus_plus/models/session.dart';
|
|||
import 'package:contacts_plus_plus/models/user_profile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Friend extends Comparable {
|
||||
class Friend implements Comparable {
|
||||
final String id;
|
||||
final String username;
|
||||
final String ownerId;
|
||||
|
@ -18,7 +18,7 @@ class Friend extends Comparable {
|
|||
factory Friend.fromMap(Map map) {
|
||||
return Friend(
|
||||
id: map["id"],
|
||||
username: map["friendUsername"],
|
||||
username: map["friendUsername"] ?? map["username"],
|
||||
ownerId: map["ownerId"] ?? map["id"],
|
||||
userStatus: UserStatus.fromMap(map["userStatus"]),
|
||||
userProfile: UserProfile.fromMap(map["profile"] ?? {}),
|
||||
|
@ -140,7 +140,7 @@ class UserStatus {
|
|||
|
||||
Map toMap({bool shallow=false}) {
|
||||
return {
|
||||
"onlineStatus": onlineStatus.index,
|
||||
"onlineStatus": onlineStatus.name,
|
||||
"lastStatusChange": lastStatusChange.toIso8601String(),
|
||||
"activeSessions": shallow ? [] : activeSessions.map((e) => e.toMap(),),
|
||||
"neosVersion": neosVersion,
|
||||
|
|
15
lib/models/inventory/neos_path.dart
Normal file
15
lib/models/inventory/neos_path.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
import 'package:contacts_plus_plus/models/asset/record.dart';
|
||||
|
||||
class NeosPath {
|
||||
final String name;
|
||||
final NeosPath? parent;
|
||||
final List<NeosPath> children;
|
||||
final Record? record;
|
||||
|
||||
const NeosPath({required this.name, required this.parent, required this.children, required this.record});
|
||||
|
||||
String get absolute {
|
||||
if (parent == null) return name;
|
||||
return "${parent!.absolute}\\$name";
|
||||
}
|
||||
}
|
|
@ -37,7 +37,7 @@ enum MessageState {
|
|||
read,
|
||||
}
|
||||
|
||||
class Message extends Comparable {
|
||||
class Message implements Comparable {
|
||||
final String id;
|
||||
final String recipientId;
|
||||
final String senderId;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||
import 'package:contacts_plus_plus/models/friend.dart';
|
||||
import 'package:contacts_plus_plus/models/sem_ver.dart';
|
||||
|
||||
|
@ -34,26 +35,25 @@ class SettingsEntry<T> {
|
|||
|
||||
class Settings {
|
||||
final SettingsEntry<bool> notificationsDenied;
|
||||
final SettingsEntry<int> unreadCheckIntervalMinutes;
|
||||
final SettingsEntry<String> publicMachineId;
|
||||
final SettingsEntry<int> lastOnlineStatus;
|
||||
final SettingsEntry<String> lastDismissedVersion;
|
||||
|
||||
Settings({
|
||||
SettingsEntry<bool>? notificationsDenied,
|
||||
SettingsEntry<int>? unreadCheckIntervalMinutes,
|
||||
SettingsEntry<String>? publicMachineId,
|
||||
SettingsEntry<int>? lastOnlineStatus,
|
||||
SettingsEntry<String>? lastDismissedVersion
|
||||
})
|
||||
: notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false),
|
||||
unreadCheckIntervalMinutes = unreadCheckIntervalMinutes ?? const SettingsEntry<int>(deflt: 60),
|
||||
publicMachineId = publicMachineId ?? SettingsEntry<String>(deflt: Aux.generateMachineId(),),
|
||||
lastOnlineStatus = lastOnlineStatus ?? SettingsEntry<int>(deflt: OnlineStatus.online.index),
|
||||
lastDismissedVersion = lastDismissedVersion ?? SettingsEntry<String>(deflt: SemVer.zero().toString())
|
||||
;
|
||||
lastDismissedVersion = lastDismissedVersion ?? SettingsEntry<String>(deflt: SemVer.zero().toString());
|
||||
|
||||
factory Settings.fromMap(Map map) {
|
||||
return Settings(
|
||||
notificationsDenied: retrieveEntryOrNull<bool>(map["notificationsDenied"]),
|
||||
unreadCheckIntervalMinutes: retrieveEntryOrNull<int>(map["unreadCheckIntervalMinutes"]),
|
||||
publicMachineId: retrieveEntryOrNull<String>(map["publicMachineId"]),
|
||||
lastOnlineStatus: retrieveEntryOrNull<int>(map["lastOnlineStatus"]),
|
||||
lastDismissedVersion: retrieveEntryOrNull<String>(map["lastDismissedVersion"])
|
||||
);
|
||||
|
@ -71,7 +71,7 @@ class Settings {
|
|||
Map toMap() {
|
||||
return {
|
||||
"notificationsDenied": notificationsDenied.toMap(),
|
||||
"unreadCheckIntervalMinutes": unreadCheckIntervalMinutes.toMap(),
|
||||
"publicMachineId": publicMachineId.toMap(),
|
||||
"lastOnlineStatus": lastOnlineStatus.toMap(),
|
||||
"lastDismissedVersion": lastDismissedVersion.toMap(),
|
||||
};
|
||||
|
@ -81,13 +81,13 @@ class Settings {
|
|||
|
||||
Settings copyWith({
|
||||
bool? notificationsDenied,
|
||||
int? unreadCheckIntervalMinutes,
|
||||
String? publicMachineId,
|
||||
int? lastOnlineStatus,
|
||||
String? lastDismissedVersion,
|
||||
}) {
|
||||
return Settings(
|
||||
notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied),
|
||||
unreadCheckIntervalMinutes: this.unreadCheckIntervalMinutes.passThrough(unreadCheckIntervalMinutes),
|
||||
publicMachineId: this.publicMachineId.passThrough(publicMachineId),
|
||||
lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus),
|
||||
lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion),
|
||||
);
|
||||
|
|
|
@ -5,8 +5,8 @@ class UserProfile {
|
|||
|
||||
factory UserProfile.empty() => UserProfile(iconUrl: "");
|
||||
|
||||
factory UserProfile.fromMap(Map map) {
|
||||
return UserProfile(iconUrl: map["iconUrl"] ?? "");
|
||||
factory UserProfile.fromMap(Map? map) {
|
||||
return UserProfile(iconUrl: map?["iconUrl"] ?? "");
|
||||
}
|
||||
|
||||
Map toMap() {
|
||||
|
|
212
lib/string_formatter.dart
Normal file
212
lib/string_formatter.dart
Normal file
|
@ -0,0 +1,212 @@
|
|||
import 'package:color/color.dart' as cc;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FormatNode {
|
||||
String? text;
|
||||
final FormatData format;
|
||||
final List<FormatNode> children;
|
||||
|
||||
FormatNode({this.text, required this.format, required this.children});
|
||||
|
||||
TextSpan toTextSpan({required TextStyle baseStyle}) {
|
||||
return TextSpan(
|
||||
text: text,
|
||||
style: format.isUnformatted ? baseStyle : format.style(baseStyle),
|
||||
children: children.map((e) => e.toTextSpan(baseStyle: baseStyle)).toList()
|
||||
);
|
||||
}
|
||||
|
||||
static FormatNode buildFromStyles(List<FormatData> styles, String text) {
|
||||
if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text);
|
||||
final root = FormatNode(format: styles.first, children: []);
|
||||
var current = root;
|
||||
for (final style in styles.sublist(1)) {
|
||||
final next = FormatNode(format: style, children: []);
|
||||
current.children.add(next);
|
||||
current = next;
|
||||
}
|
||||
current.text = text;
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
class StringFormatter {
|
||||
static TextSpan? tryFormat(String text, {TextStyle? baseStyle}) {
|
||||
try {
|
||||
final content = StringFormatter.format(text, baseStyle: baseStyle);
|
||||
if ((content.children?.isEmpty ?? true) && content.style == null) {
|
||||
return null;
|
||||
}
|
||||
return content;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static TextSpan format(String text, {TextStyle? baseStyle}) {
|
||||
baseStyle ??= const TextStyle();
|
||||
var tags = parseTags(text);
|
||||
if (tags.isEmpty) return TextSpan(text: text, style: null, children: const []);
|
||||
final root = FormatNode(
|
||||
format: FormatData.unformatted(),
|
||||
text: text.substring(0, tags.first.startIndex),
|
||||
children: [],
|
||||
);
|
||||
|
||||
final activeTags = <FormatData>[];
|
||||
|
||||
for (int i = 0; i < tags.length; i++) {
|
||||
final tag = tags[i];
|
||||
final substr = text.substring(tag.endIndex, (i + 1 < tags.length) ? tags[i + 1].startIndex : null);
|
||||
if (tag.format.isAdditive) {
|
||||
activeTags.add(tag.format);
|
||||
} else {
|
||||
final idx = activeTags.lastIndexWhere((element) => element.name == tag.format.name);
|
||||
if (idx != -1) {
|
||||
activeTags.removeAt(idx);
|
||||
}
|
||||
}
|
||||
if (substr.isNotEmpty) {
|
||||
root.children.add(
|
||||
FormatNode.buildFromStyles(activeTags, substr)
|
||||
);
|
||||
}
|
||||
}
|
||||
return root.toTextSpan(baseStyle: baseStyle);
|
||||
}
|
||||
|
||||
static List<FormatTag> parseTags(String text) {
|
||||
final startMatches = RegExp(r"<(.+?)>").allMatches(text);
|
||||
|
||||
final spans = <FormatTag>[];
|
||||
|
||||
for (final startMatch in startMatches) {
|
||||
final fullTag = startMatch.group(1);
|
||||
if (fullTag == null) continue;
|
||||
final tag = FormatData.parse(fullTag);
|
||||
spans.add(
|
||||
FormatTag(
|
||||
startIndex: startMatch.start,
|
||||
endIndex: startMatch.end,
|
||||
format: tag,
|
||||
)
|
||||
);
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
|
||||
class FormatTag {
|
||||
final int startIndex;
|
||||
final int endIndex;
|
||||
final FormatData format;
|
||||
|
||||
const FormatTag({
|
||||
required this.startIndex,
|
||||
required this.endIndex,
|
||||
required this.format,
|
||||
});
|
||||
}
|
||||
|
||||
class FormatAction {
|
||||
final String Function(String input, String parameter)? transform;
|
||||
final TextStyle Function(String? parameter, TextStyle baseStyle)? style;
|
||||
|
||||
FormatAction({this.transform, this.style});
|
||||
}
|
||||
|
||||
class FormatData {
|
||||
static Color? tryParseColor(String? text) {
|
||||
if (text == null) return null;
|
||||
var color = cc.RgbColor.namedColors[text];
|
||||
if (color != null) {
|
||||
return Color.fromARGB(255, color.r.round(), color.g.round(), color.b.round());
|
||||
}
|
||||
try {
|
||||
color = cc.HexColor(text);
|
||||
return Color.fromARGB(255, color.r.round(), color.g.round(), color.b.round());
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static final Map<String, FormatAction> _richTextTags = {
|
||||
"align": FormatAction(),
|
||||
"alpha": FormatAction(style: (param, baseStyle) {
|
||||
if (param == null || !param.startsWith("#")) return baseStyle;
|
||||
final alpha = int.tryParse(param.substring(1), radix: 16);
|
||||
if (alpha == null) return baseStyle;
|
||||
return baseStyle.copyWith(color: baseStyle.color?.withAlpha(alpha));
|
||||
}),
|
||||
"color": FormatAction(style: (param, baseStyle) {
|
||||
if (param == null) return baseStyle;
|
||||
final color = tryParseColor(param);
|
||||
if (color == null) return baseStyle;
|
||||
return baseStyle.copyWith(color: color);
|
||||
}),
|
||||
"b": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(fontWeight: FontWeight.bold)),
|
||||
"br": FormatAction(transform: (text, param) => "\n$text"),
|
||||
"i": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(fontStyle: FontStyle.italic)),
|
||||
"cspace": FormatAction(),
|
||||
"font": FormatAction(),
|
||||
"indent": FormatAction(),
|
||||
"line-height": FormatAction(),
|
||||
"line-indent": FormatAction(),
|
||||
"link": FormatAction(),
|
||||
"lowercase": FormatAction(),
|
||||
"uppercase": FormatAction(),
|
||||
"smallcaps": FormatAction(),
|
||||
"margin": FormatAction(),
|
||||
"mark": FormatAction(style: (param, baseStyle) {
|
||||
if (param == null) return baseStyle;
|
||||
final color = tryParseColor(param);
|
||||
if (color == null) return baseStyle;
|
||||
return baseStyle.copyWith(backgroundColor: color);
|
||||
}),
|
||||
"mspace": FormatAction(),
|
||||
"noparse": FormatAction(),
|
||||
"nobr": FormatAction(),
|
||||
"page": FormatAction(),
|
||||
"pos": FormatAction(),
|
||||
"size": FormatAction(),
|
||||
"space": FormatAction(),
|
||||
"sprite": FormatAction(),
|
||||
"s": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(decoration: TextDecoration.lineThrough)),
|
||||
"u": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(decoration: TextDecoration.underline)),
|
||||
"style": FormatAction(),
|
||||
"sub": FormatAction(),
|
||||
"sup": FormatAction(),
|
||||
"voffset": FormatAction(),
|
||||
"width": FormatAction(),
|
||||
};
|
||||
|
||||
final String name;
|
||||
final String parameter;
|
||||
final bool isAdditive;
|
||||
|
||||
const FormatData({required this.name, required this.parameter, required this.isAdditive});
|
||||
|
||||
factory FormatData.parse(String text) {
|
||||
if (text.contains("/")) return FormatData(name: text.replaceAll("/", ""), parameter: "", isAdditive: false);
|
||||
final sepIdx = text.indexOf("=");
|
||||
if (sepIdx == -1) {
|
||||
return FormatData(name: text, parameter: "", isAdditive: true);
|
||||
} else {
|
||||
return FormatData(
|
||||
name: text.substring(0, sepIdx).trim().toLowerCase(),
|
||||
parameter: text.substring(sepIdx + 1, text.length).trim().toLowerCase(),
|
||||
isAdditive: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
factory FormatData.unformatted() => const FormatData(name: "", parameter: "", isAdditive: false);
|
||||
|
||||
bool get isUnformatted => name.isEmpty && parameter.isEmpty && !isAdditive;
|
||||
|
||||
bool get isValid => _richTextTags.containsKey(name);
|
||||
|
||||
String? apply(String? text) => text == null ? null : _richTextTags[name]?.transform?.call(text, parameter);
|
||||
|
||||
TextStyle style(TextStyle baseStyle) => _richTextTags[name]?.style?.call(parameter, baseStyle) ?? baseStyle;
|
||||
}
|
|
@ -31,12 +31,98 @@ class FriendsList extends StatefulWidget {
|
|||
State<FriendsList> createState() => _FriendsListState();
|
||||
}
|
||||
|
||||
class _FriendsListState extends State<FriendsList> {
|
||||
Future<PersonalProfile>? _userProfileFuture;
|
||||
Future<UserStatus>? _userStatusFuture;
|
||||
ClientHolder? _clientHolder;
|
||||
class _FriendsListState extends State<FriendsList> with AutomaticKeepAliveClientMixin {
|
||||
String _searchFilter = "";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Consumer<MessagingClient>(
|
||||
builder: (context, mClient, _) {
|
||||
if (mClient.initStatus == null) {
|
||||
return const LinearProgressIndicator();
|
||||
} else if (mClient.initStatus!.isNotEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DefaultErrorWidget(
|
||||
message: mClient.initStatus,
|
||||
onRetry: () async {
|
||||
mClient.resetStatus();
|
||||
mClient.refreshFriendsListWithErrorHandler();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
var friends = List.from(mClient.cachedFriends); // Explicit copy.
|
||||
if (_searchFilter.isNotEmpty) {
|
||||
friends = friends.where((element) =>
|
||||
element.username.toLowerCase().contains(_searchFilter.toLowerCase())).toList();
|
||||
friends.sort((a, b) => a.username.length.compareTo(b.username.length));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: friends.length,
|
||||
itemBuilder: (context, index) {
|
||||
final friend = friends[index];
|
||||
final unreads = mClient.getUnreadsForFriend(friend);
|
||||
return FriendListTile(
|
||||
friend: friend,
|
||||
unreads: unreads.length,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ExpandingInputFab(
|
||||
onInputChanged: (String text) {
|
||||
setState(() {
|
||||
_searchFilter = text;
|
||||
});
|
||||
},
|
||||
onExpansionChanged: (expanded) {
|
||||
if (!expanded) {
|
||||
setState(() {
|
||||
_searchFilter = "";
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// TODO: implement wantKeepAlive
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class FriendsListAppBar extends StatefulWidget implements PreferredSizeWidget {
|
||||
const FriendsListAppBar({required this.mClient, super.key});
|
||||
|
||||
// Passing this instance around like this is kinda dirty, I want to try to find a cleaner way to do this using Provider
|
||||
final MessagingClient mClient;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _FriendsListAppBarState();
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
class _FriendsListAppBarState extends State<FriendsListAppBar> {
|
||||
Future<UserStatus>? _userStatusFuture;
|
||||
Future<PersonalProfile>? _userProfileFuture;
|
||||
ClientHolder? _clientHolder;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() async {
|
||||
super.didChangeDependencies();
|
||||
|
@ -67,8 +153,7 @@ class _FriendsListState extends State<FriendsList> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clientHolder = ClientHolder.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
return AppBar(
|
||||
title: const Text("Contacts++"),
|
||||
actions: [
|
||||
FutureBuilder(
|
||||
|
@ -92,9 +177,7 @@ class _FriendsListState extends State<FriendsList> {
|
|||
setState(() {
|
||||
_userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now()));
|
||||
});
|
||||
final settingsClient = ClientHolder
|
||||
.of(context)
|
||||
.settingsClient;
|
||||
final settingsClient = clientHolder.settingsClient;
|
||||
await UserApi.setStatus(clientHolder.apiClient, status: newStatus);
|
||||
await settingsClient.changeSettings(
|
||||
settingsClient.currentSettings.copyWith(lastOnlineStatus: onlineStatus.index));
|
||||
|
@ -190,12 +273,11 @@ class _FriendsListState extends State<FriendsList> {
|
|||
name: "Find Users",
|
||||
icon: Icons.person_add,
|
||||
onTap: () async {
|
||||
final mClient = Provider.of<MessagingClient>(context, listen: false);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ChangeNotifierProvider<MessagingClient>.value(
|
||||
value: mClient,
|
||||
value: widget.mClient,
|
||||
child: const UserSearch(),
|
||||
),
|
||||
),
|
||||
|
@ -220,9 +302,7 @@ class _FriendsListState extends State<FriendsList> {
|
|||
title: "Failed to load personal profile.",
|
||||
onRetry: () {
|
||||
setState(() {
|
||||
_userProfileFuture = UserApi.getPersonalProfile(ClientHolder
|
||||
.of(context)
|
||||
.apiClient);
|
||||
_userProfileFuture = UserApi.getPersonalProfile(clientHolder.apiClient);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
@ -250,68 +330,6 @@ class _FriendsListState extends State<FriendsList> {
|
|||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Consumer<MessagingClient>(
|
||||
builder: (context, mClient, _) {
|
||||
if (mClient.initStatus == null) {
|
||||
return const LinearProgressIndicator();
|
||||
} else if (mClient.initStatus!.isNotEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DefaultErrorWidget(
|
||||
message: mClient.initStatus,
|
||||
onRetry: () async {
|
||||
mClient.resetStatus();
|
||||
mClient.refreshFriendsListWithErrorHandler();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
var friends = List.from(mClient.cachedFriends); // Explicit copy.
|
||||
if (_searchFilter.isNotEmpty) {
|
||||
friends = friends.where((element) =>
|
||||
element.username.toLowerCase().contains(_searchFilter.toLowerCase())).toList();
|
||||
friends.sort((a, b) => a.username.length.compareTo(b.username.length));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: friends.length,
|
||||
itemBuilder: (context, index) {
|
||||
final friend = friends[index];
|
||||
final unreads = mClient.getUnreadsForFriend(friend);
|
||||
return FriendListTile(
|
||||
friend: friend,
|
||||
unreads: unreads.length,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ExpandingInputFab(
|
||||
onInputChanged: (String text) {
|
||||
setState(() {
|
||||
_searchFilter = text;
|
||||
});
|
||||
},
|
||||
onExpansionChanged: (expanded) {
|
||||
if (!expanded) {
|
||||
setState(() {
|
||||
_searchFilter = "";
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -67,7 +67,7 @@ class _UserListTileState extends State<UserListTile> {
|
|||
_loading = false;
|
||||
_localAdded = !_localAdded;
|
||||
});
|
||||
widget.onChanged?.call();
|
||||
await widget.onChanged?.call();
|
||||
} catch (e, s) {
|
||||
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
|
@ -70,8 +70,10 @@ class _UserSearchState extends State<UserSearch> {
|
|||
itemCount: users.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = users[index];
|
||||
return UserListTile(user: user, onChanged: () {
|
||||
mClient.refreshFriendsList();
|
||||
return UserListTile(user: user, onChanged: () async {
|
||||
try {
|
||||
await mClient.refreshFriendsList();
|
||||
} catch (_) {}
|
||||
}, isFriend: mClient.getAsFriend(user.id) != null,);
|
||||
},
|
||||
);
|
||||
|
@ -83,8 +85,6 @@ class _UserSearchState extends State<UserSearch> {
|
|||
iconOverride: err.icon,
|
||||
);
|
||||
} else {
|
||||
FlutterError.reportError(
|
||||
FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
|
||||
return DefaultErrorWidget(title: "${snapshot.error}",);
|
||||
}
|
||||
} else {
|
||||
|
|
77
lib/widgets/home.dart
Normal file
77
lib/widgets/home.dart
Normal file
|
@ -0,0 +1,77 @@
|
|||
|
||||
import 'package:contacts_plus_plus/client_holder.dart';
|
||||
import 'package:contacts_plus_plus/clients/messaging_client.dart';
|
||||
import 'package:contacts_plus_plus/widgets/friends/friends_list.dart';
|
||||
import 'package:contacts_plus_plus/widgets/inventory/inventory_browser.dart';
|
||||
import 'package:contacts_plus_plus/widgets/sessions/sessions_list.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class Home extends StatefulWidget {
|
||||
const Home({super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _HomeState();
|
||||
}
|
||||
|
||||
class _HomeState extends State<Home> with AutomaticKeepAliveClientMixin {
|
||||
final PageController _pageController = PageController(initialPage: 1);
|
||||
ClientHolder? _clientHolder;
|
||||
late MessagingClient _mClient;
|
||||
int _currentPageIndex = 1;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() async {
|
||||
super.didChangeDependencies();
|
||||
final clientHolder = ClientHolder.of(context);
|
||||
if (_clientHolder != clientHolder) {
|
||||
_clientHolder = clientHolder;
|
||||
_mClient = MessagingClient(
|
||||
apiClient: clientHolder.apiClient,
|
||||
notificationClient: clientHolder.notificationClient,
|
||||
settingsClient: clientHolder.settingsClient,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Scaffold(
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentPageIndex,
|
||||
onDestinationSelected: (int index) async {
|
||||
setState(() {
|
||||
_currentPageIndex = index;
|
||||
});
|
||||
await _pageController.animateToPage(index,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCirc,
|
||||
);
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.folder_copy), label: "Inventory"),
|
||||
NavigationDestination(icon: Icon(Icons.chat), label: "Contacts"),
|
||||
NavigationDestination(icon: Icon(Icons.location_city), label: "Sessions")
|
||||
],
|
||||
),
|
||||
appBar: FriendsListAppBar(mClient: _mClient,),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: _pageController,
|
||||
children: [
|
||||
const InventoryBrowser(),
|
||||
ChangeNotifierProvider
|
||||
.value( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
|
||||
value: _mClient,
|
||||
child: const FriendsList(),
|
||||
),
|
||||
const SessionsList(),
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
231
lib/widgets/inventory/inventory_browser.dart
Normal file
231
lib/widgets/inventory/inventory_browser.dart
Normal file
|
@ -0,0 +1,231 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:contacts_plus_plus/apis/asset_api.dart';
|
||||
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||
import 'package:contacts_plus_plus/client_holder.dart';
|
||||
import 'package:contacts_plus_plus/models/asset/record.dart';
|
||||
import 'package:contacts_plus_plus/models/inventory/neos_path.dart';
|
||||
import 'package:contacts_plus_plus/widgets/default_error_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class InventoryBrowser extends StatefulWidget {
|
||||
const InventoryBrowser({super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _InventoryBrowserState();
|
||||
}
|
||||
|
||||
class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepAliveClientMixin {
|
||||
static const Duration _refreshLimit = Duration(seconds: 60);
|
||||
Timer? _refreshLimiter;
|
||||
Future<List<Record>>? _inventoryFuture;
|
||||
final NeosPath _inventoryRoot = const NeosPath(name: "Inventory", parent: null, children: [], record: null);
|
||||
late NeosPath _currentPath = _inventoryRoot;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_inventoryFuture = _currentPathFuture();
|
||||
}
|
||||
|
||||
Future<List<Record>> _currentPathFuture() => AssetApi.getRecordsAt(
|
||||
ClientHolder.of(context).apiClient,
|
||||
path: _currentPath.absolute,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
if (_refreshLimiter?.isActive ?? false) return;
|
||||
try {
|
||||
final records = await _currentPathFuture();
|
||||
setState(() {
|
||||
_inventoryFuture = Future.value(records);
|
||||
});
|
||||
_refreshLimiter = Timer(_refreshLimit, () {});
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Refresh failed: $e")));
|
||||
}
|
||||
},
|
||||
child: FutureBuilder(
|
||||
future: _inventoryFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final records = snapshot.data as List<Record>;
|
||||
records.sort((a, b) => a.name.compareTo(b.name));
|
||||
final paths = records.where((element) => element.recordType == RecordType.link
|
||||
|| element.recordType == RecordType.directory).toList();
|
||||
final objects = records.where((element) =>
|
||||
element.recordType != RecordType.link && element.recordType != RecordType.directory).toList();
|
||||
return ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 0, 16),
|
||||
child: Text(
|
||||
"${_currentPath.absolute}:",
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: paths.length,
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 256,
|
||||
childAspectRatio: 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final record = paths[index];
|
||||
return PathInventoryTile(record: record);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8,),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: objects.length,
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 256,
|
||||
childAspectRatio: 1,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final record = objects[index];
|
||||
return ObjectInventoryTile(record: record);
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
|
||||
return DefaultErrorWidget(
|
||||
message: snapshot.error.toString(),
|
||||
onRetry: () async {
|
||||
setState(() {
|
||||
_inventoryFuture = null;
|
||||
});
|
||||
setState(() {
|
||||
_inventoryFuture = _currentPathFuture();
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: const [
|
||||
LinearProgressIndicator(),
|
||||
Spacer(),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class ObjectInventoryTile extends StatelessWidget {
|
||||
ObjectInventoryTile({required this.record, super.key});
|
||||
|
||||
final Record record;
|
||||
final DateFormat _dateFormat = DateFormat.yMd();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16)),
|
||||
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: Aux.neosDbToHttp(record.thumbnailUri),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
record.formattedName == null
|
||||
? Text(record.name, maxLines: 2, overflow: TextOverflow.ellipsis)
|
||||
: RichText(text: record.formattedName!, maxLines: 2, overflow: TextOverflow.ellipsis,),
|
||||
if (record.creationTime != null) Row(
|
||||
children: [
|
||||
const Icon(Icons.access_time, size: 12, color: Colors.white54,),
|
||||
const SizedBox(width: 4,),
|
||||
Text(_dateFormat.format(record.creationTime!), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white54),),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PathInventoryTile extends StatelessWidget {
|
||||
const PathInventoryTile({required this.record, super.key});
|
||||
|
||||
final Record record;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16)),
|
||||
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
alignment: Alignment.centerLeft
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
},
|
||||
icon: record.recordType == RecordType.directory ? const Icon(Icons.folder) : const Icon(Icons.link),
|
||||
label: record.formattedName == null
|
||||
? Text(record.name, maxLines: 3, overflow: TextOverflow.ellipsis)
|
||||
: RichText(text: record.formattedName!, maxLines: 3, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:contacts_plus_plus/models/message.dart';
|
||||
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||
import 'package:contacts_plus_plus/widgets/messages/message_asset.dart';
|
||||
import 'package:contacts_plus_plus/widgets/messages/message_audio_player.dart';
|
||||
import 'package:contacts_plus_plus/widgets/messages/message_session_invite.dart';
|
||||
|
@ -60,6 +61,13 @@ class MyMessageBubble extends StatelessWidget {
|
|||
);
|
||||
case MessageType.unknown:
|
||||
case MessageType.text:
|
||||
final formatted = StringFormatter.tryFormat(
|
||||
message.content,
|
||||
baseStyle: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodyLarge,
|
||||
);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
@ -79,7 +87,7 @@ class MyMessageBubble extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formatted == null ? Text(
|
||||
message.content,
|
||||
softWrap: true,
|
||||
maxLines: null,
|
||||
|
@ -87,7 +95,7 @@ class MyMessageBubble extends StatelessWidget {
|
|||
.of(context)
|
||||
.textTheme
|
||||
.bodyLarge,
|
||||
),
|
||||
) : RichText(text: formatted, maxLines: null, softWrap: true,),
|
||||
const SizedBox(height: 6,),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
@ -148,7 +156,6 @@ class OtherMessageBubble extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var content = message.content;
|
||||
switch (message.type) {
|
||||
case MessageType.sessionInvite:
|
||||
return Row(
|
||||
|
@ -193,8 +200,14 @@ class OtherMessageBubble extends StatelessWidget {
|
|||
],
|
||||
);
|
||||
case MessageType.unknown:
|
||||
rawText:
|
||||
case MessageType.text:
|
||||
final formatted = StringFormatter.tryFormat(
|
||||
message.content,
|
||||
baseStyle: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodyLarge,
|
||||
);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
|
@ -214,15 +227,15 @@ class OtherMessageBubble extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
content,
|
||||
formatted == null ? Text(
|
||||
message.content,
|
||||
softWrap: true,
|
||||
maxLines: null,
|
||||
style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodyLarge,
|
||||
),
|
||||
) : RichText(text: formatted, maxLines: null, softWrap: true,),
|
||||
const SizedBox(height: 6,),
|
||||
Text(
|
||||
_dateFormat.format(message.sendTime),
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:contacts_plus_plus/client_holder.dart';
|
|||
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||
import 'package:contacts_plus_plus/models/message.dart';
|
||||
import 'package:contacts_plus_plus/models/session.dart';
|
||||
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||
import 'package:contacts_plus_plus/widgets/generic_avatar.dart';
|
||||
import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart';
|
||||
import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart';
|
||||
|
@ -18,6 +19,7 @@ class MessageSessionInvite extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sessionInfo = Session.fromMap(jsonDecode(message.content));
|
||||
final formattedName = StringFormatter.tryFormat(sessionInfo.name, baseStyle: Theme.of(context).textTheme.titleMedium);
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
showDialog(context: context, builder: (context) => SessionPopup(session: sessionInfo));
|
||||
|
@ -38,7 +40,8 @@ class MessageSessionInvite extends StatelessWidget {
|
|||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(sessionInfo.name, maxLines: null, softWrap: true, style: Theme.of(context).textTheme.titleMedium,),
|
||||
child: formattedName != null ? RichText(text: formattedName, maxLines: null, softWrap: true) :
|
||||
Text(sessionInfo.name, maxLines: null, softWrap: true, style: Theme.of(context).textTheme.titleMedium,),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||
import 'package:contacts_plus_plus/models/session.dart';
|
||||
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||
import 'package:contacts_plus_plus/widgets/generic_avatar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -13,6 +14,7 @@ class SessionPopup extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final ScrollController userListScrollController = ScrollController();
|
||||
final thumbnailUri = Aux.neosDbToHttp(session.thumbnail);
|
||||
final formattedTitle = StringFormatter.tryFormat(session.name);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(32),
|
||||
child: Container(
|
||||
|
@ -30,7 +32,8 @@ class SessionPopup extends StatelessWidget {
|
|||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
Text(session.name, style: Theme.of(context).textTheme.titleMedium),
|
||||
formattedTitle == null ?
|
||||
Text(session.name, style: Theme.of(context).textTheme.titleMedium) : RichText(text: formattedTitle),
|
||||
Text(session.description.isEmpty ? "No description." : session.description, style: Theme.of(context).textTheme.labelMedium),
|
||||
Text("Tags: ${session.tags.isEmpty ? "None" : session.tags.join(", ")}",
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
|
@ -114,6 +117,7 @@ class SessionTile extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formattedTitle = StringFormatter.tryFormat(session.name);
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
showDialog(context: context, builder: (context) => SessionPopup(session: session));
|
||||
|
@ -128,7 +132,7 @@ class SessionTile extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(session.name),
|
||||
formattedTitle == null ? Text(session.name) : RichText(text: formattedTitle),
|
||||
Text("${session.sessionUsers.length}/${session.maxUsers} active users")
|
||||
],
|
||||
),
|
||||
|
|
60
lib/widgets/sessions/session_tile.dart
Normal file
60
lib/widgets/sessions/session_tile.dart
Normal file
|
@ -0,0 +1,60 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||
import 'package:contacts_plus_plus/models/session.dart';
|
||||
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||
import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LargeSessionTile extends StatelessWidget {
|
||||
const LargeSessionTile({required this.session, super.key});
|
||||
|
||||
final Session session;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formattedName = StringFormatter.tryFormat(session.name, baseStyle: const TextStyle(color: Colors.white));
|
||||
return InkWell(
|
||||
onTap: (){
|
||||
showDialog(context: context, builder: (context) => SessionPopup(session: session));
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
Aux.neosDbToHttp(session.thumbnail),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4),
|
||||
color: Theme.of(context).colorScheme.background.withAlpha(200),
|
||||
child: formattedName != null ? RichText(text: formattedName, maxLines: 4, overflow: TextOverflow.ellipsis)
|
||||
: Text(session.name.overflow, maxLines: 4, overflow: TextOverflow.ellipsis,),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4),
|
||||
color: Theme.of(context).colorScheme.background.withAlpha(200),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("${session.sessionUsers.length}/${session.maxUsers}"),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
113
lib/widgets/sessions/sessions_list.dart
Normal file
113
lib/widgets/sessions/sessions_list.dart
Normal file
|
@ -0,0 +1,113 @@
|
|||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:contacts_plus_plus/apis/session_api.dart';
|
||||
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||
import 'package:contacts_plus_plus/client_holder.dart';
|
||||
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||
import 'package:contacts_plus_plus/models/session.dart';
|
||||
import 'package:contacts_plus_plus/widgets/default_error_widget.dart';
|
||||
import 'package:contacts_plus_plus/widgets/friends/expanding_input_fab.dart';
|
||||
import 'package:contacts_plus_plus/widgets/sessions/session_tile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SessionsList extends StatefulWidget {
|
||||
const SessionsList({super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _SessionsListState();
|
||||
|
||||
}
|
||||
|
||||
class _SessionsListState extends State<SessionsList> with AutomaticKeepAliveClientMixin {
|
||||
Timer? _refreshDelay;
|
||||
Future<List<Session>>? _sessionsFuture;
|
||||
String _searchFilter = "";
|
||||
|
||||
ClientHolder? _clientHolder;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() async {
|
||||
super.didChangeDependencies();
|
||||
final clientHolder = ClientHolder.of(context);
|
||||
if (_clientHolder != clientHolder) {
|
||||
_clientHolder = clientHolder;
|
||||
final apiClient = _clientHolder!.apiClient;
|
||||
_refreshSessions(apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshSessions(ApiClient client) {
|
||||
if (_refreshDelay?.isActive ?? false) return;
|
||||
_sessionsFuture = SessionApi.getSessions(client);
|
||||
_refreshDelay = Timer(const Duration(seconds: 30), (){});
|
||||
}
|
||||
|
||||
List<Session> _filterSessions(List<Session> sessions, {String text=""}) {
|
||||
return sessions.where((element) => element.name.looseMatch(text)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
_refreshSessions(ClientHolder
|
||||
.of(context)
|
||||
.apiClient);
|
||||
await _sessionsFuture; // Keep showing indicator until done;
|
||||
},
|
||||
child: FutureBuilder(
|
||||
future: _sessionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final sessions = _filterSessions(snapshot.data as List<Session>, text: _searchFilter);
|
||||
return GridView.builder(
|
||||
itemCount: sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
return LargeSessionTile(session: sessions[index]);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 256),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return DefaultErrorWidget(
|
||||
title: "Failed to load sessions",
|
||||
message: snapshot.error.toString(),
|
||||
onRetry: () =>
|
||||
_refreshSessions(ClientHolder
|
||||
.of(context)
|
||||
.apiClient),
|
||||
);
|
||||
} else {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ExpandingInputFab(
|
||||
onInputChanged: (String text) {
|
||||
setState(() {
|
||||
_searchFilter = text;
|
||||
});
|
||||
},
|
||||
onExpansionChanged: (expanded) {
|
||||
if (!expanded) {
|
||||
setState(() {
|
||||
_searchFilter = "";
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
|
@ -5,15 +5,6 @@ import 'package:url_launcher/url_launcher.dart';
|
|||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
static const Map<int, String> _intervalSelections = {
|
||||
5: "5 Minutes",
|
||||
15: "15 Minutes",
|
||||
30: "30 Minutes",
|
||||
60: "1 Hour",
|
||||
120: "2 Hours",
|
||||
300: "6 Hours",
|
||||
600: "12 Hours",
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -36,22 +27,6 @@ class SettingsPage extends StatelessWidget {
|
|||
initialState: !sClient.currentSettings.notificationsDenied.valueOrDefault,
|
||||
onChanged: (value) async => await sClient.changeSettings(sClient.currentSettings.copyWith(notificationsDenied: !value)),
|
||||
),
|
||||
ListTile(
|
||||
trailing: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return DropdownButton<int>(
|
||||
items: _intervalSelections.keys.map((e) => DropdownMenuItem<int>(value: e, child: Text("${_intervalSelections[e]}"))).toList(),
|
||||
value: sClient.currentSettings.unreadCheckIntervalMinutes.valueOrDefault,
|
||||
onChanged: (int? value) async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Sorry, this feature is not yet implemented.")));
|
||||
await sClient.changeSettings(sClient.currentSettings.copyWith(unreadCheckIntervalMinutes: value));
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
title: const Text("Check Interval"),
|
||||
),
|
||||
const ListSectionHeader(name: "Other"),
|
||||
ListTile(
|
||||
trailing: const Icon(Icons.logout),
|
||||
|
|
68
pubspec.lock
68
pubspec.lock
|
@ -25,6 +25,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.13"
|
||||
bbob_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bbob_dart
|
||||
sha256: d754e0dfd800582a6f0a43ae4f12db8eb763e89f584674c334a36e0faaddb1f9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -81,14 +89,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67
|
||||
name: color
|
||||
sha256: ddcdf1b3badd7008233f5acffaf20ca9f5dc2cd0172b75f68f24526a5f5725cb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "3.0.0"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -137,11 +153,27 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.4"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: e6c7ad8e572379df86ea64ef0a5395889fba3954411d47ca021b888d79f8e798
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.11"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_bbcode:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_bbcode
|
||||
sha256: "024cb7d3b32d8f7dd155251d09bb92f496aaccb19cfe7313caea2e85d491ac7f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
flutter_blurhash:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -198,6 +230,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "96af49aa6b57c10a312106ad6f71deed5a754029c24789bbf620ba784f0bd0b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.14"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -264,6 +304,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
hive:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive
|
||||
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -417,13 +465,13 @@ packages:
|
|||
source: hosted
|
||||
version: "1.8.2"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: c7edf82217d4b2952b2129a61d3ad60f1075b9299e629e149a8d2e39c2e6aad4
|
||||
sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.14"
|
||||
version: "2.0.15"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -737,10 +785,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: a6f0236dbda0f63aa9a25ad1ff9a9d8a4eaaa5012da0dc59d21afdb1dc361ca4
|
||||
sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
version: "4.1.4"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.1.1+1
|
||||
version: 1.2.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=2.19.6 <3.0.0'
|
||||
|
@ -31,7 +31,6 @@ dependencies:
|
|||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
|
@ -55,6 +54,12 @@ dependencies:
|
|||
provider: ^6.0.5
|
||||
full_screen_image: ^2.0.0
|
||||
photo_view: ^0.14.0
|
||||
file_picker: ^5.2.11
|
||||
crypto: ^3.0.3
|
||||
flutter_bbcode: ^1.4.0
|
||||
color: ^3.0.0
|
||||
path_provider: ^2.0.15
|
||||
hive: ^2.2.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Reference in a new issue