Merge pull request #22 from Nutcake/media-upload

Media upload
This commit is contained in:
Nutcake 2023-05-28 19:31:30 +02:00 committed by GitHub
commit 533e4d4297
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 5372 additions and 602 deletions

View file

@ -3,6 +3,9 @@
<!-- Required to fetch data from the internet. --> <!-- Required to fetch data from the internet. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Optional, you'll have to check this permission by yourself. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application <application
android:label="Contacts++" android:label="Contacts++"
android:name="${applicationName}" android:name="${applicationName}"

View file

@ -6,7 +6,7 @@ import 'package:contacts_plus_plus/models/friend.dart';
class FriendApi { class FriendApi {
static Future<List<Friend>> getFriendsList(ApiClient client, {DateTime? lastStatusUpdate}) async { static Future<List<Friend>> getFriendsList(ApiClient client, {DateTime? lastStatusUpdate}) async {
final response = await client.get("/users/${client.userId}/friends${lastStatusUpdate != null ? "?lastStatusUpdate=${lastStatusUpdate.toUtc().toIso8601String()}" : ""}"); final response = await client.get("/users/${client.userId}/friends${lastStatusUpdate != null ? "?lastStatusUpdate=${lastStatusUpdate.toUtc().toIso8601String()}" : ""}");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body) as List; final data = jsonDecode(response.body) as List;
return data.map((e) => Friend.fromMap(e)).toList(); return data.map((e) => Friend.fromMap(e)).toList();
} }

View file

@ -13,7 +13,7 @@ class MessageApi {
"${userId.isEmpty ? "" : "&user=$userId"}" "${userId.isEmpty ? "" : "&user=$userId"}"
"&unread=$unreadOnly" "&unread=$unreadOnly"
); );
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body) as List; final data = jsonDecode(response.body) as List;
return data.map((e) => Message.fromMap(e)).toList(); return data.map((e) => Message.fromMap(e)).toList();
} }

225
lib/apis/record_api.dart Normal file
View file

@ -0,0 +1,225 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:contacts_plus_plus/models/records/asset_digest.dart';
import 'package:contacts_plus_plus/models/records/json_template.dart';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/models/records/asset_upload_data.dart';
import 'package:contacts_plus_plus/models/records/neos_db_asset.dart';
import 'package:contacts_plus_plus/models/records/preprocess_status.dart';
import 'package:contacts_plus_plus/models/records/record.dart';
import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart';
class RecordApi {
static Future<List<Record>> getRecordsAt(ApiClient client, {required String path}) async {
final response = await client.get("/users/${client.userId}/records?path=$path");
client.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 body = jsonEncode(record.toMap());
final response = await client.post(
"/users/${record.ownerId}/records/${record.id}/preprocess", body: body);
client.checkResponse(response);
final resultBody = jsonDecode(response.body);
return PreprocessStatus.fromMap(resultBody);
}
static Future<PreprocessStatus> getPreprocessStatus(ApiClient client,
{required PreprocessStatus preprocessStatus}) async {
final response = await client.get(
"/users/${preprocessStatus.ownerId}/records/${preprocessStatus.recordId}/preprocess/${preprocessStatus.id}"
);
client.checkResponse(response);
final body = jsonDecode(response.body);
return PreprocessStatus.fromMap(body);
}
static Future<PreprocessStatus> tryPreprocessRecord(ApiClient client, {required Record record}) async {
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}";
}
return status;
}
static Future<AssetUploadData> beginUploadAsset(ApiClient client, {required NeosDBAsset asset}) async {
final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks");
client.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);
client.checkResponse(response);
}
static Future<void> uploadAsset(ApiClient client,
{required AssetUploadData uploadData, required String filename, required NeosDBAsset asset, required Uint8List data, void Function(double number)? progressCallback}) async {
for (int i = 0; i < uploadData.totalChunks; i++) {
progressCallback?.call(i/uploadData.totalChunks);
final offset = i * uploadData.chunkSize;
final end = (i + 1) * uploadData.chunkSize;
final request = http.MultipartRequest(
"POST",
ApiClient.buildFullUri("/users/${client.userId}/assets/${asset.hash}/chunks/$i"),
)
..files.add(http.MultipartFile.fromBytes(
"file", data.getRange(offset, min(end, data.length)).toList(), filename: filename,
contentType: MediaType.parse("multipart/form-data")))
..headers.addAll(client.authorizationHeader);
final response = await request.send();
final bodyBytes = await response.stream.toBytes();
client.checkResponse(http.Response.bytes(bodyBytes, response.statusCode));
progressCallback?.call(1);
}
}
static Future<void> finishUpload(ApiClient client, {required NeosDBAsset asset}) async {
final response = await client.patch("/users/${client.userId}/assets/${asset.hash}/chunks");
client.checkResponse(response);
}
static Future<void> uploadAssets(ApiClient client, {required List<AssetDigest> assets, void Function(double progress)? progressCallback}) async {
progressCallback?.call(0);
for (int i = 0; i < assets.length; i++) {
final totalProgress = i/assets.length;
progressCallback?.call(totalProgress);
final entry = assets[i];
final uploadData = await beginUploadAsset(client, asset: entry.asset);
if (uploadData.uploadState == UploadState.failed) {
throw "Asset upload failed: ${uploadData.uploadState.name}";
}
await uploadAsset(client,
uploadData: uploadData,
asset: entry.asset,
data: entry.data,
filename: entry.name,
progressCallback: (progress) => progressCallback?.call(totalProgress + progress * 1/assets.length),
);
await finishUpload(client, asset: entry.asset);
}
progressCallback?.call(1);
}
static Future<Record> uploadImage(ApiClient client, {required File image, required String machineId, void Function(double progress)? progressCallback}) async {
progressCallback?.call(0);
final imageDigest = await AssetDigest.fromData(await image.readAsBytes(), basename(image.path));
final imageData = await decodeImageFromList(imageDigest.data);
final filename = basenameWithoutExtension(image.path);
final objectJson = jsonEncode(
JsonTemplate.image(imageUri: imageDigest.dbUri, filename: filename, width: imageData.width, height: imageData.height).data);
final objectBytes = Uint8List.fromList(utf8.encode(objectJson));
final objectDigest = await AssetDigest.fromData(objectBytes, "${basenameWithoutExtension(image.path)}.json");
final digests = [imageDigest, objectDigest];
final record = Record.fromRequiredData(
recordType: RecordType.texture,
userId: client.userId,
machineId: machineId,
assetUri: objectDigest.dbUri,
filename: filename,
thumbnailUri: imageDigest.dbUri,
digests: digests,
extraTags: ["image"],
);
progressCallback?.call(.1);
final status = await tryPreprocessRecord(client, record: record);
final toUpload = status.resultDiffs.whereNot((element) => element.isUploaded);
progressCallback?.call(.2);
await uploadAssets(
client,
assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(),
progressCallback: (progress) => progressCallback?.call(.2 + progress * .6));
await upsertRecord(client, record: record);
progressCallback?.call(1);
return record;
}
static Future<Record> uploadVoiceClip(ApiClient client, {required File voiceClip, required String machineId, void Function(double progress)? progressCallback}) async {
progressCallback?.call(0);
final voiceDigest = await AssetDigest.fromData(await voiceClip.readAsBytes(), basename(voiceClip.path));
final filename = basenameWithoutExtension(voiceClip.path);
final digests = [voiceDigest];
final record = Record.fromRequiredData(
recordType: RecordType.audio,
userId: client.userId,
machineId: machineId,
assetUri: voiceDigest.dbUri,
filename: filename,
thumbnailUri: "",
digests: digests,
extraTags: ["voice", "message"],
);
progressCallback?.call(.1);
final status = await tryPreprocessRecord(client, record: record);
final toUpload = status.resultDiffs.whereNot((element) => element.isUploaded);
progressCallback?.call(.2);
await uploadAssets(
client,
assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(),
progressCallback: (progress) => progressCallback?.call(.2 + progress * .6));
await upsertRecord(client, record: record);
progressCallback?.call(1);
return record;
}
static Future<Record> uploadRawFile(ApiClient client, {required File file, required String machineId, void Function(double progress)? progressCallback}) async {
progressCallback?.call(0);
final fileDigest = await AssetDigest.fromData(await file.readAsBytes(), basename(file.path));
final objectJson = jsonEncode(JsonTemplate.rawFile(assetUri: fileDigest.dbUri, filename: fileDigest.name).data);
final objectBytes = Uint8List.fromList(utf8.encode(objectJson));
final objectDigest = await AssetDigest.fromData(objectBytes, "${basenameWithoutExtension(file.path)}.json");
final digests = [fileDigest, objectDigest];
final record = Record.fromRequiredData(
recordType: RecordType.texture,
userId: client.userId,
machineId: machineId,
assetUri: objectDigest.dbUri,
filename: fileDigest.name,
thumbnailUri: JsonTemplate.thumbUrl,
digests: digests,
extraTags: ["document"],
);
progressCallback?.call(.1);
final status = await tryPreprocessRecord(client, record: record);
final toUpload = status.resultDiffs.whereNot((element) => element.isUploaded);
progressCallback?.call(.2);
await uploadAssets(
client,
assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(),
progressCallback: (progress) => progressCallback?.call(.2 + progress * .6));
await upsertRecord(client, record: record);
progressCallback?.call(1);
return record;
}
}

View file

@ -10,43 +10,44 @@ import 'package:package_info_plus/package_info_plus.dart';
class UserApi { class UserApi {
static Future<Iterable<User>> searchUsers(ApiClient client, {required String needle}) async { static Future<Iterable<User>> searchUsers(ApiClient client, {required String needle}) async {
final response = await client.get("/users?name=$needle"); final response = await client.get("/users?name=$needle");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body) as List; final data = jsonDecode(response.body) as List;
return data.map((e) => User.fromMap(e)); return data.map((e) => User.fromMap(e));
} }
static Future<User> getUser(ApiClient client, {required String userId}) async { static Future<User> getUser(ApiClient client, {required String userId}) async {
final response = await client.get("/users/$userId/"); final response = await client.get("/users/$userId/");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
return User.fromMap(data); return User.fromMap(data);
} }
static Future<UserStatus> getUserStatus(ApiClient client, {required String userId}) async { static Future<UserStatus> getUserStatus(ApiClient client, {required String userId}) async {
final response = await client.get("/users/$userId/status"); final response = await client.get("/users/$userId/status");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
return UserStatus.fromMap(data); return UserStatus.fromMap(data);
} }
static Future<void> notifyOnlineInstance(ApiClient client) async { static Future<void> notifyOnlineInstance(ApiClient client) async {
final response = await client.post("/stats/instanceOnline/${client.authenticationData.secretMachineId.hashCode}"); final response = await client.post("/stats/instanceOnline/${client.authenticationData.secretMachineId.hashCode}");
ApiClient.checkResponse(response); client.checkResponse(response);
} }
static Future<void> setStatus(ApiClient client, {required UserStatus status}) async { static Future<void> setStatus(ApiClient client, {required UserStatus status}) async {
final pkginfo = await PackageInfo.fromPlatform(); final pkginfo = await PackageInfo.fromPlatform();
status = status.copyWith( status = status.copyWith(
neosVersion: "${pkginfo.version} of ${pkginfo.appName}", neosVersion: "${pkginfo.version} of ${pkginfo.appName}",
isMobile: true,
); );
final body = jsonEncode(status.toMap(shallow: true)); final body = jsonEncode(status.toMap(shallow: true));
final response = await client.put("/users/${client.userId}/status", body: body); final response = await client.put("/users/${client.userId}/status", body: body);
ApiClient.checkResponse(response); client.checkResponse(response);
} }
static Future<PersonalProfile> getPersonalProfile(ApiClient client) async { static Future<PersonalProfile> getPersonalProfile(ApiClient client) async {
final response = await client.get("/users/${client.userId}"); final response = await client.get("/users/${client.userId}");
ApiClient.checkResponse(response); client.checkResponse(response);
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
return PersonalProfile.fromMap(data); return PersonalProfile.fromMap(data);
} }
@ -63,11 +64,11 @@ class UserApi {
); );
final body = jsonEncode(friend.toMap(shallow: true)); final body = jsonEncode(friend.toMap(shallow: true));
final response = await client.put("/users/${client.userId}/friends/${user.id}", body: body); final response = await client.put("/users/${client.userId}/friends/${user.id}", body: body);
ApiClient.checkResponse(response); client.checkResponse(response);
} }
static Future<void> removeUserAsFriend(ApiClient client, {required User user}) async { static Future<void> removeUserAsFriend(ApiClient client, {required User user}) async {
final response = await client.delete("/users/${client.userId}/friends/${user.id}"); final response = await client.delete("/users/${client.userId}/friends/${user.id}");
ApiClient.checkResponse(response); client.checkResponse(response);
} }
} }

View file

@ -1,4 +1,5 @@
import 'package:contacts_plus_plus/config.dart'; import 'package:contacts_plus_plus/config.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:html/parser.dart' as htmlparser; import 'package:html/parser.dart' as htmlparser;
@ -86,3 +87,18 @@ extension Format on Duration {
} }
} }
} }
extension DateTimeX on DateTime {
static DateTime epoch = DateTime.fromMillisecondsSinceEpoch(0);
static DateTime one = DateTime(1);
}
extension ColorX on Color {
Color invert() {
final r = 255 - red;
final g = 255 - green;
final b = 255 - blue;
return Color.fromARGB((opacity * 255).round(), r, g, b);
}
}

View file

@ -14,8 +14,9 @@ class ClientHolder extends InheritedWidget {
super.key, super.key,
required AuthenticationData authenticationData, required AuthenticationData authenticationData,
required this.settingsClient, required this.settingsClient,
required super.child required super.child,
}) : apiClient = ApiClient(authenticationData: authenticationData); required Function() onLogout,
}) : apiClient = ApiClient(authenticationData: authenticationData, onLogout: onLogout);
static ClientHolder? maybeOf(BuildContext context) { static ClientHolder? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ClientHolder>(); return context.dependOnInheritedWidgetOfExactType<ClientHolder>();
@ -30,5 +31,6 @@ class ClientHolder extends InheritedWidget {
@override @override
bool updateShouldNotify(covariant ClientHolder oldWidget) => bool updateShouldNotify(covariant ClientHolder oldWidget) =>
oldWidget.apiClient != apiClient oldWidget.apiClient != apiClient
|| oldWidget.settingsClient != settingsClient; || oldWidget.settingsClient != settingsClient
|| oldWidget.notificationClient != notificationClient;
} }

View file

@ -1,8 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:contacts_plus_plus/models/authentication_data.dart'; import 'package:contacts_plus_plus/models/authentication_data.dart';
@ -18,10 +16,12 @@ class ApiClient {
static const String tokenKey = "token"; static const String tokenKey = "token";
static const String passwordKey = "password"; static const String passwordKey = "password";
ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData; ApiClient({required AuthenticationData authenticationData, required this.onLogout}) : _authenticationData = authenticationData;
final AuthenticationData _authenticationData; final AuthenticationData _authenticationData;
final Logger _logger = Logger("API"); final Logger _logger = Logger("API");
// Saving the context here feels kinda cringe ngl
final Function() onLogout;
AuthenticationData get authenticationData => _authenticationData; AuthenticationData get authenticationData => _authenticationData;
String get userId => _authenticationData.userId; String get userId => _authenticationData.userId;
@ -31,7 +31,7 @@ class ApiClient {
required String username, required String username,
required String password, required String password,
bool rememberMe=true, bool rememberMe=true,
bool rememberPass=false, bool rememberPass=true,
String? oneTimePad, String? oneTimePad,
}) async { }) async {
final body = { final body = {
@ -54,11 +54,13 @@ class ApiClient {
if (response.statusCode == 400) { if (response.statusCode == 400) {
throw "Invalid Credentials"; throw "Invalid Credentials";
} }
checkResponse(response); checkResponseCode(response);
final authData = AuthenticationData.fromMap(jsonDecode(response.body)); final authData = AuthenticationData.fromMap(jsonDecode(response.body));
if (authData.isAuthenticated) { if (authData.isAuthenticated) {
const FlutterSecureStorage storage = FlutterSecureStorage(); const FlutterSecureStorage storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
await storage.write(key: userIdKey, value: authData.userId); await storage.write(key: userIdKey, value: authData.userId);
await storage.write(key: machineIdKey, value: authData.secretMachineId); await storage.write(key: machineIdKey, value: authData.secretMachineId);
await storage.write(key: tokenKey, value: authData.token); await storage.write(key: tokenKey, value: authData.token);
@ -68,7 +70,9 @@ class ApiClient {
} }
static Future<AuthenticationData> tryCachedLogin() async { static Future<AuthenticationData> tryCachedLogin() async {
const FlutterSecureStorage storage = FlutterSecureStorage(); const FlutterSecureStorage storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
String? userId = await storage.read(key: userIdKey); String? userId = await storage.read(key: userIdKey);
String? machineId = await storage.read(key: machineIdKey); String? machineId = await storage.read(key: machineIdKey);
String? token = await storage.read(key: tokenKey); String? token = await storage.read(key: tokenKey);
@ -79,7 +83,7 @@ class ApiClient {
} }
if (token != null) { if (token != null) {
final response = await http.get(buildFullUri("/users/$userId"), headers: { final response = await http.patch(buildFullUri("/userSessions"), headers: {
"Authorization": "neos $userId:$token" "Authorization": "neos $userId:$token"
}); });
if (response.statusCode == 200) { if (response.statusCode == 200) {
@ -99,15 +103,15 @@ class ApiClient {
return AuthenticationData.unauthenticated(); return AuthenticationData.unauthenticated();
} }
Future<void> logout(BuildContext context) async { Future<void> logout() async {
const FlutterSecureStorage storage = FlutterSecureStorage(); const FlutterSecureStorage storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
await storage.delete(key: userIdKey); await storage.delete(key: userIdKey);
await storage.delete(key: machineIdKey); await storage.delete(key: machineIdKey);
await storage.delete(key: tokenKey); await storage.delete(key: tokenKey);
await storage.delete(key: passwordKey); await storage.delete(key: passwordKey);
if (context.mounted) { onLogout();
Phoenix.rebirth(context);
}
} }
Future<void> extendSession() async { Future<void> extendSession() async {
@ -117,22 +121,30 @@ class ApiClient {
} }
} }
static void checkResponse(http.Response response) { void checkResponse(http.Response response) {
final error = "(${response.statusCode}${kDebugMode ? "|${response.body}" : ""})";
if (response.statusCode == 429) {
throw "Sorry, you are being rate limited. $error";
}
if (response.statusCode == 403) { if (response.statusCode == 403) {
tryCachedLogin(); tryCachedLogin().then((value) {
// TODO: Show the login screen again if cached login was unsuccessful. if (!value.isAuthenticated) {
throw "You are not authorized to do that. $error"; onLogout();
} }
if (response.statusCode == 500) { });
throw "Internal server error. $error";
}
if (response.statusCode >= 300) {
throw "Unknown Error. $error";
} }
checkResponseCode(response);
}
static void checkResponseCode(http.Response response) {
if (response.statusCode < 300) return;
final error = "${switch (response.statusCode) {
429 => "You are being rate limited.",
403 => "You are not authorized to do that.",
404 => "Resource not found.",
500 => "Internal server error.",
_ => "Unknown Error."
}} (${response.statusCode}${kDebugMode ? "|${response.body}" : ""})";
FlutterError.reportError(FlutterErrorDetails(exception: error));
throw error;
} }
Map<String, String> get authorizationHeader => _authenticationData.authorizationHeader; Map<String, String> get authorizationHeader => _authenticationData.authorizationHeader;

View file

@ -0,0 +1,24 @@
import 'dart:io';
import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:http/http.dart' as http;
import 'package:contacts_plus_plus/models/message.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
class AudioCacheClient {
final Future<Directory> _directoryFuture = getTemporaryDirectory();
Future<File> cachedNetworkAudioFile(AudioClipContent clip) async {
final directory = await _directoryFuture;
final file = File("${directory.path}/${basename(clip.assetUri)}");
if (!await file.exists()) {
await file.create(recursive: true);
final response = await http.get(Uri.parse(Aux.neosDbToHttp(clip.assetUri)));
ApiClient.checkResponseCode(response);
await file.writeAsBytes(response.bodyBytes);
}
return file;
}
}

View file

@ -72,6 +72,7 @@ class MessagingClient extends ChangeNotifier {
box.delete(_lastUpdateKey); box.delete(_lastUpdateKey);
await refreshFriendsListWithErrorHandler(); await refreshFriendsListWithErrorHandler();
await _refreshUnreads(); await _refreshUnreads();
_unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads());
}); });
_startWebsocket(); _startWebsocket();
_notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async { _notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async {
@ -85,6 +86,7 @@ class MessagingClient extends ChangeNotifier {
void dispose() { void dispose() {
_autoRefresh?.cancel(); _autoRefresh?.cancel();
_notifyOnlineTimer?.cancel(); _notifyOnlineTimer?.cancel();
_unreadSafeguard?.cancel();
_wsChannel?.close(); _wsChannel?.close();
super.dispose(); super.dispose();
} }
@ -142,7 +144,7 @@ class MessagingClient extends ChangeNotifier {
}; };
_sendData(data); _sendData(data);
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId); final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
cache.messages.add(message); cache.addMessage(message);
notifyListeners(); notifyListeners();
} }
@ -217,12 +219,10 @@ class MessagingClient extends ChangeNotifier {
} }
Future<void> _refreshUnreads() async { Future<void> _refreshUnreads() async {
_unreadSafeguard?.cancel();
try { try {
final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true); final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true);
updateAllUnreads(unreadMessages.toList()); updateAllUnreads(unreadMessages.toList());
} catch (_) {} } catch (_) {}
_unreadSafeguard = Timer(_unreadSafeguardDuration, _refreshUnreads);
} }
void _sortFriendsCache() { void _sortFriendsCache() {
@ -286,7 +286,7 @@ class MessagingClient extends ChangeNotifier {
Uri.parse("${Config.neosHubUrl}/negotiate"), Uri.parse("${Config.neosHubUrl}/negotiate"),
headers: _apiClient.authorizationHeader, headers: _apiClient.authorizationHeader,
); );
ApiClient.checkResponse(response); _apiClient.checkResponse(response);
} catch (e) { } catch (e) {
throw "Failed to acquire connection info from Neos API: $e"; throw "Failed to acquire connection info from Neos API: $e";
} }

View file

@ -15,7 +15,6 @@ class SettingsClient {
final data = await _storage.read(key: _settingsKey); final data = await _storage.read(key: _settingsKey);
if (data == null) return; if (data == null) return;
_currentSettings = Settings.fromMap(jsonDecode(data)); _currentSettings = Settings.fromMap(jsonDecode(data));
} }
Future<void> changeSettings(Settings newSettings) async { Future<void> changeSettings(Settings newSettings) async {

View file

@ -1,8 +1,8 @@
import 'dart:developer'; import 'dart:developer';
import 'dart:io' show Platform;
import 'package:contacts_plus_plus/apis/github_api.dart'; import 'package:contacts_plus_plus/apis/github_api.dart';
import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/clients/settings_client.dart'; import 'package:contacts_plus_plus/clients/settings_client.dart';
import 'package:contacts_plus_plus/models/sem_ver.dart'; import 'package:contacts_plus_plus/models/sem_ver.dart';
@ -27,13 +27,20 @@ void main() async {
Logger.root.onRecord.listen((event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time)); Logger.root.onRecord.listen((event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
final settingsClient = SettingsClient(); final settingsClient = SettingsClient();
await settingsClient.loadSettings(); await settingsClient.loadSettings();
runApp(Phoenix(child: ContactsPlusPlus(settingsClient: settingsClient,))); final newSettings = settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault);
await settingsClient.changeSettings(newSettings); // Save generated machineId to disk
AuthenticationData cachedAuth = AuthenticationData.unauthenticated();
try {
cachedAuth = await ApiClient.tryCachedLogin();
} catch (_) {}
runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth));
} }
class ContactsPlusPlus extends StatefulWidget { class ContactsPlusPlus extends StatefulWidget {
const ContactsPlusPlus({required this.settingsClient, super.key}); const ContactsPlusPlus({required this.settingsClient, required this.cachedAuthentication, super.key});
final SettingsClient settingsClient; final SettingsClient settingsClient;
final AuthenticationData cachedAuthentication;
@override @override
State<ContactsPlusPlus> createState() => _ContactsPlusPlusState(); State<ContactsPlusPlus> createState() => _ContactsPlusPlusState();
@ -41,7 +48,7 @@ class ContactsPlusPlus extends StatefulWidget {
class _ContactsPlusPlusState extends State<ContactsPlusPlus> { class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
final Typography _typography = Typography.material2021(platform: TargetPlatform.android); final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
AuthenticationData _authData = AuthenticationData.unauthenticated(); late AuthenticationData _authData = widget.cachedAuthentication;
bool _checkedForUpdate = false; bool _checkedForUpdate = false;
void showUpdateDialogOnFirstBuild(BuildContext context) { void showUpdateDialogOnFirstBuild(BuildContext context) {
@ -95,43 +102,61 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClientHolder( return Phoenix(
settingsClient: widget.settingsClient, child: Builder(
authenticationData: _authData, builder: (context) {
child: DynamicColorBuilder( return ClientHolder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp( settingsClient: widget.settingsClient,
debugShowCheckedModeBanner: false, authenticationData: _authData,
title: 'Contacts++', onLogout: () {
theme: ThemeData( setState(() {
useMaterial3: true, _authData = AuthenticationData.unauthenticated();
textTheme: _typography.white, });
colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), Phoenix.rebirth(context);
), },
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder child: DynamicColorBuilder(
builder: (context) { builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp(
showUpdateDialogOnFirstBuild(context); debugShowCheckedModeBanner: false,
final clientHolder = ClientHolder.of(context); title: 'Contacts++',
return _authData.isAuthenticated ? theme: ThemeData(
ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. useMaterial3: true,
create: (context) => textTheme: _typography.black,
MessagingClient( colorScheme: lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.light),
apiClient: clientHolder.apiClient, ),
notificationClient: clientHolder.notificationClient, darkTheme: ThemeData(
), useMaterial3: true,
child: const FriendsList(), textTheme: _typography.white,
) : colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark),
LoginScreen( ),
onLoginSuccessful: (AuthenticationData authData) async { themeMode: ThemeMode.values[widget.settingsClient.currentSettings.themeMode.valueOrDefault],
if (authData.isAuthenticated) { home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder
setState(() { builder: (context) {
_authData = authData; 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(
onLoginSuccessful: (AuthenticationData authData) async {
if (authData.isAuthenticated) {
setState(() {
_authData = authData;
});
}
},
);
} }
}, )
); ),
} ),
) );
), }
), ),
); );
} }

View file

@ -93,14 +93,14 @@ enum OnlineStatus {
online; online;
static final List<Color> _colors = [ static final List<Color> _colors = [
Colors.white54, Colors.transparent,
Colors.white54, Colors.transparent,
Colors.yellow, Colors.yellow,
Colors.red, Colors.red,
Colors.green, Colors.green,
]; ];
Color get color => _colors[index]; Color color(BuildContext context) => this == OnlineStatus.offline || this == OnlineStatus.invisible ? Theme.of(context).colorScheme.onSurface : _colors[index];
factory OnlineStatus.fromString(String? text) { factory OnlineStatus.fromString(String? text) {
return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),

View file

@ -49,7 +49,7 @@ class Message implements Comparable {
final MessageState state; final MessageState state;
Message({required this.id, required this.recipientId, required this.senderId, required this.type, Message({required this.id, required this.recipientId, required this.senderId, required this.type,
required this.content, required DateTime sendTime, this.state=MessageState.local}) required this.content, required DateTime sendTime, required this.state})
: formattedContent = FormatNode.fromText(content), sendTime = sendTime.toUtc(); : formattedContent = FormatNode.fromText(content), sendTime = sendTime.toUtc();
factory Message.fromMap(Map map, {MessageState? withState}) { factory Message.fromMap(Map map, {MessageState? withState}) {
@ -65,7 +65,7 @@ class Message implements Comparable {
type: type, type: type,
content: map["content"], content: map["content"],
sendTime: DateTime.parse(map["sendTime"]), sendTime: DateTime.parse(map["sendTime"]),
state: withState ?? (map["readTime"] != null ? MessageState.read : MessageState.local) state: withState ?? (map["readTime"] != null ? MessageState.read : MessageState.sent)
); );
} }
@ -125,7 +125,7 @@ class MessageCache {
bool addMessage(Message message) { bool addMessage(Message message) {
final existingIdx = _messages.indexWhere((element) => element.id == message.id); final existingIdx = _messages.indexWhere((element) => element.id == message.id);
if (existingIdx == -1) { if (existingIdx == -1) {
_messages.add(message); _messages.insert(0, message);
_ensureIntegrity(); _ensureIntegrity();
} else { } else {
_messages[existingIdx] = message; _messages[existingIdx] = message;
@ -175,7 +175,7 @@ class AudioClipContent {
final String id; final String id;
final String assetUri; final String assetUri;
AudioClipContent({required this.id, required this.assetUri}); const AudioClipContent({required this.id, required this.assetUri});
factory AudioClipContent.fromMap(Map map) { factory AudioClipContent.fromMap(Map map) {
return AudioClipContent( return AudioClipContent(
@ -190,7 +190,7 @@ class MarkReadBatch {
final List<String> ids; final List<String> ids;
final DateTime readTime; final DateTime readTime;
MarkReadBatch({required this.senderId, required this.ids, required this.readTime}); const MarkReadBatch({required this.senderId, required this.ids, required this.readTime});
Map toMap() { Map toMap() {
return { return {

View file

@ -0,0 +1,34 @@
import 'package:contacts_plus_plus/models/records/neos_db_asset.dart';
class AssetDiff extends NeosDBAsset{
final Diff state;
final bool isUploaded;
const AssetDiff({required hash, required bytes, required this.state, required this.isUploaded}) : super(hash: hash, bytes: bytes);
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,
);
}
}

View file

@ -0,0 +1,25 @@
import 'dart:typed_data';
import 'package:contacts_plus_plus/models/records/neos_db_asset.dart';
import 'package:path/path.dart';
class AssetDigest {
final Uint8List data;
final NeosDBAsset asset;
final String name;
final String dbUri;
AssetDigest({required this.data, required this.asset, required this.name, required this.dbUri});
static Future<AssetDigest> fromData(Uint8List data, String filename) async {
final asset = NeosDBAsset.fromData(data);
return AssetDigest(
data: data,
asset: asset,
name: basenameWithoutExtension(filename),
dbUri: "neosdb:///${asset.hash}${extension(filename)}",
);
}
}

View 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"]),
);
}
}

File diff suppressed because it is too large Load diff

View 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,
};
}
}

View file

@ -0,0 +1,41 @@
import 'package:contacts_plus_plus/models/records/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(),
);
}
}

View file

@ -0,0 +1,303 @@
import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/models/records/asset_digest.dart';
import 'package:contacts_plus_plus/models/records/neos_db_asset.dart';
import 'package:contacts_plus_plus/string_formatter.dart';
import 'package:flutter/material.dart';
import 'package: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 RecordId {
final String? id;
final String? ownerId;
final bool isValid;
const RecordId({required this.id, required this.ownerId, required this.isValid});
factory RecordId.fromMap(Map? map) {
return RecordId(id: map?["id"], ownerId: map?["ownerId"], isValid: map?["isValid"] ?? false);
}
Map toMap() {
return {
"id": id,
"ownerId": ownerId,
"isValid": isValid,
};
}
}
class Record {
final String id;
final RecordId combinedRecordId;
final String ownerId;
final String assetUri;
final int globalVersion;
final int localVersion;
final String lastModifyingUserId;
final String lastModifyingMachineId;
final bool isSynced;
final DateTime fetchedOn;
final String name;
final FormatNode formattedName;
final String description;
final RecordType recordType;
final List<String> tags;
final String path;
final String thumbnailUri;
final bool isPublic;
final bool isForPatreons;
final bool isListed;
final DateTime lastModificationTime;
final DateTime creationTime;
final int visits;
final int rating;
final int randomOrder;
final List<String> manifest;
final List<NeosDBAsset> neosDBManifest;
final String url;
final bool isValidOwnerId;
final bool isValidRecordId;
Record({
required this.id,
required this.combinedRecordId,
required this.isSynced,
required this.fetchedOn,
required this.path,
required this.ownerId,
required this.assetUri,
required this.name,
required this.description,
required this.tags,
required this.recordType,
required this.thumbnailUri,
required this.isPublic,
required this.isListed,
required this.isForPatreons,
required this.lastModificationTime,
required this.neosDBManifest,
required this.lastModifyingUserId,
required this.lastModifyingMachineId,
required this.creationTime,
required this.manifest,
required this.url,
required this.isValidOwnerId,
required this.isValidRecordId,
required this.globalVersion,
required this.localVersion,
required this.visits,
required this.rating,
required this.randomOrder,
}) : formattedName = FormatNode.fromText(name);
factory Record.fromRequiredData({
required RecordType recordType,
required String userId,
required String machineId,
required String assetUri,
required String filename,
required String thumbnailUri,
required List<AssetDigest> digests,
List<String>? extraTags,
}) {
final combinedRecordId = RecordId(id: Record.generateId(), ownerId: userId, isValid: true);
return Record(
id: combinedRecordId.id.toString(),
combinedRecordId: combinedRecordId,
assetUri: assetUri,
name: filename,
tags: ([
filename,
"message_item",
"message_id:${Message.generateId()}",
"contacts-plus-plus"
] + (extraTags ?? [])).unique(),
recordType: recordType,
thumbnailUri: thumbnailUri,
isPublic: false,
isForPatreons: false,
isListed: false,
neosDBManifest: digests.map((e) => e.asset).toList(),
globalVersion: 0,
localVersion: 1,
lastModifyingUserId: userId,
lastModifyingMachineId: machineId,
lastModificationTime: DateTime.now().toUtc(),
creationTime: DateTime.now().toUtc(),
ownerId: userId,
isSynced: false,
fetchedOn: DateTimeX.one,
path: '',
description: '',
manifest: digests.map((e) => e.dbUri).toList(),
url: "neosrec:///$userId/${combinedRecordId.id}",
isValidOwnerId: true,
isValidRecordId: true,
visits: 0,
rating: 0,
randomOrder: 0,
);
}
factory Record.fromMap(Map map) {
return Record(
id: map["id"] ?? "0",
combinedRecordId: RecordId.fromMap(map["combinedRecordId"]),
ownerId: map["ownerId"] ?? "",
assetUri: map["assetUri"] ?? "",
globalVersion: map["globalVersion"] ?? 0,
localVersion: map["localVersion"] ?? 0,
name: map["name"] ?? "",
description: map["description"] ?? "",
tags: (map["tags"] as List? ?? []).map((e) => e.toString()).toList(),
recordType: RecordType.fromName(map["recordType"]),
thumbnailUri: map["thumbnailUri"] ?? "",
isPublic: map["isPublic"] ?? false,
isForPatreons: map["isForPatreons"] ?? false,
isListed: map["isListed"] ?? false,
lastModificationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch,
neosDBManifest: (map["neosDBManifest"] as List? ?? []).map((e) => NeosDBAsset.fromMap(e)).toList(),
lastModifyingUserId: map["lastModifyingUserId"] ?? "",
lastModifyingMachineId: map["lastModifyingMachineId"] ?? "",
creationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch,
isSynced: map["isSynced"] ?? false,
fetchedOn: DateTime.tryParse(map["fetchedOn"]) ?? DateTimeX.epoch,
path: map["path"] ?? "",
manifest: (map["neosDBManifest"] as List? ?? []).map((e) => e.toString()).toList(),
url: map["url"] ?? "",
isValidOwnerId: map["isValidOwnerId"] ?? "",
isValidRecordId: map["isValidRecordId"] ?? "",
visits: map["visits"] ?? 0,
rating: map["rating"] ?? 0,
randomOrder: map["randomOrder"] ?? 0
);
}
Record copyWith({
String? id,
String? ownerId,
String? recordId,
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,
RecordId? combinedRecordId,
bool? isSynced,
DateTime? fetchedOn,
String? path,
List<String>? manifest,
String? url,
bool? isValidOwnerId,
bool? isValidRecordId,
int? visits,
int? rating,
int? randomOrder,
}) {
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,
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,
lastModificationTime: lastModificationTime ?? this.lastModificationTime,
neosDBManifest: neosDBManifest ?? this.neosDBManifest,
lastModifyingUserId: lastModifyingUserId ?? this.lastModifyingUserId,
lastModifyingMachineId: lastModifyingMachineId ?? this.lastModifyingMachineId,
creationTime: creationTime ?? this.creationTime,
combinedRecordId: combinedRecordId ?? this.combinedRecordId,
isSynced: isSynced ?? this.isSynced,
fetchedOn: fetchedOn ?? this.fetchedOn,
path: path ?? this.path,
manifest: manifest ?? this.manifest,
url: url ?? this.url,
isValidOwnerId: isValidOwnerId ?? this.isValidOwnerId,
isValidRecordId: isValidRecordId ?? this.isValidRecordId,
visits: visits ?? this.visits,
rating: rating ?? this.rating,
randomOrder: randomOrder ?? this.randomOrder,
);
}
Map toMap() {
return {
"id": id,
"ownerId": ownerId,
"assetUri": assetUri,
"globalVersion": globalVersion,
"localVersion": localVersion,
"name": name,
"description": description.asNullable,
"tags": tags,
"recordType": recordType.name,
"thumbnailUri": thumbnailUri.asNullable,
"isPublic": isPublic,
"isForPatreons": isForPatreons,
"isListed": isListed,
"lastModificationTime": lastModificationTime.toUtc().toIso8601String(),
"neosDBManifest": neosDBManifest.map((e) => e.toMap()).toList(),
"lastModifyingUserId": lastModifyingUserId,
"lastModifyingMachineId": lastModifyingMachineId,
"creationTime": creationTime.toUtc().toIso8601String(),
"combinedRecordId": combinedRecordId.toMap(),
"isSynced": isSynced,
"fetchedOn": fetchedOn.toUtc().toIso8601String(),
"path": path.asNullable,
"manifest": manifest,
"url": url,
"isValidOwnerId": isValidOwnerId,
"isValidRecordId": isValidRecordId,
"visits": visits,
"rating": rating,
"randomOrder": randomOrder,
};
}
static String generateId() {
return "R-${const Uuid().v4()}";
}
String? extractMessageId() {
const key = "message_id:";
for (final tag in tags) {
if (tag.startsWith(key)) {
return tag.replaceFirst(key, "");
}
}
return null;
}
}

View file

@ -2,6 +2,8 @@ import 'dart:convert';
import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/sem_ver.dart'; import 'package:contacts_plus_plus/models/sem_ver.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
class SettingsEntry<T> { class SettingsEntry<T> {
final T? value; final T? value;
@ -36,22 +38,29 @@ class Settings {
final SettingsEntry<bool> notificationsDenied; final SettingsEntry<bool> notificationsDenied;
final SettingsEntry<int> lastOnlineStatus; final SettingsEntry<int> lastOnlineStatus;
final SettingsEntry<String> lastDismissedVersion; final SettingsEntry<String> lastDismissedVersion;
final SettingsEntry<String> machineId;
final SettingsEntry<int> themeMode;
Settings({ Settings({
SettingsEntry<bool>? notificationsDenied, SettingsEntry<bool>? notificationsDenied,
SettingsEntry<int>? lastOnlineStatus, SettingsEntry<int>? lastOnlineStatus,
SettingsEntry<String>? lastDismissedVersion SettingsEntry<int>? themeMode,
SettingsEntry<String>? lastDismissedVersion,
SettingsEntry<String>? machineId
}) })
: notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false), : notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false),
lastOnlineStatus = lastOnlineStatus ?? SettingsEntry<int>(deflt: OnlineStatus.online.index), lastOnlineStatus = lastOnlineStatus ?? SettingsEntry<int>(deflt: OnlineStatus.online.index),
lastDismissedVersion = lastDismissedVersion ?? SettingsEntry<String>(deflt: SemVer.zero().toString()) themeMode = themeMode ?? SettingsEntry<int>(deflt: ThemeMode.dark.index),
; lastDismissedVersion = lastDismissedVersion ?? SettingsEntry<String>(deflt: SemVer.zero().toString()),
machineId = machineId ?? SettingsEntry<String>(deflt: const Uuid().v4());
factory Settings.fromMap(Map map) { factory Settings.fromMap(Map map) {
return Settings( return Settings(
notificationsDenied: retrieveEntryOrNull<bool>(map["notificationsDenied"]), notificationsDenied: retrieveEntryOrNull<bool>(map["notificationsDenied"]),
lastOnlineStatus: retrieveEntryOrNull<int>(map["lastOnlineStatus"]), lastOnlineStatus: retrieveEntryOrNull<int>(map["lastOnlineStatus"]),
lastDismissedVersion: retrieveEntryOrNull<String>(map["lastDismissedVersion"]) themeMode: retrieveEntryOrNull<int>(map["themeMode"]),
lastDismissedVersion: retrieveEntryOrNull<String>(map["lastDismissedVersion"]),
machineId: retrieveEntryOrNull<String>(map["machineId"]),
); );
} }
@ -68,7 +77,9 @@ class Settings {
return { return {
"notificationsDenied": notificationsDenied.toMap(), "notificationsDenied": notificationsDenied.toMap(),
"lastOnlineStatus": lastOnlineStatus.toMap(), "lastOnlineStatus": lastOnlineStatus.toMap(),
"themeMode": themeMode.toMap(),
"lastDismissedVersion": lastDismissedVersion.toMap(), "lastDismissedVersion": lastDismissedVersion.toMap(),
"machineId": machineId.toMap(),
}; };
} }
@ -76,14 +87,17 @@ class Settings {
Settings copyWith({ Settings copyWith({
bool? notificationsDenied, bool? notificationsDenied,
int? unreadCheckIntervalMinutes,
int? lastOnlineStatus, int? lastOnlineStatus,
int? themeMode,
String? lastDismissedVersion, String? lastDismissedVersion,
String? machineId,
}) { }) {
return Settings( return Settings(
notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied), notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied),
lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus), lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus),
themeMode: this.themeMode.passThrough(themeMode),
lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion), lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion),
machineId: this.machineId.passThrough(machineId),
); );
} }
} }

View file

@ -15,11 +15,11 @@ class FriendOnlineStatusIndicator extends StatelessWidget {
child: Image.asset( child: Image.asset(
"assets/images/logo-white.png", "assets/images/logo-white.png",
alignment: Alignment.center, alignment: Alignment.center,
color: userStatus.onlineStatus.color, color: userStatus.onlineStatus.color(context),
), ),
) : Icon( ) : Icon(
userStatus.onlineStatus == OnlineStatus.offline ? Icons.circle_outlined : Icons.circle, userStatus.onlineStatus == OnlineStatus.offline ? Icons.circle_outlined : Icons.circle,
color: userStatus.onlineStatus.color, color: userStatus.onlineStatus.color(context),
size: 10, size: 10,
); );
} }

View file

@ -4,7 +4,6 @@ import 'package:contacts_plus_plus/apis/user_api.dart';
import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/personal_profile.dart';
import 'package:contacts_plus_plus/widgets/default_error_widget.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/friends/expanding_input_fab.dart';
import 'package:contacts_plus_plus/widgets/friends/friend_list_tile.dart'; import 'package:contacts_plus_plus/widgets/friends/friend_list_tile.dart';
@ -42,7 +41,6 @@ class _FriendsListState extends State<FriendsList> {
final clientHolder = ClientHolder.of(context); final clientHolder = ClientHolder.of(context);
if (_clientHolder != clientHolder) { if (_clientHolder != clientHolder) {
_clientHolder = clientHolder; _clientHolder = clientHolder;
final apiClient = _clientHolder!.apiClient;
_refreshUserStatus(); _refreshUserStatus();
} }
} }
@ -79,7 +77,7 @@ class _FriendsListState extends State<FriendsList> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(right: 8.0), padding: const EdgeInsets.only(right: 8.0),
child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color,), child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color(context),),
), ),
Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"), Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"),
], ],
@ -114,7 +112,7 @@ class _FriendsListState extends State<FriendsList> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Icon(Icons.circle, size: 16, color: item.color,), Icon(Icons.circle, size: 16, color: item.color(context),),
const SizedBox(width: 8,), const SizedBox(width: 8,),
Text(toBeginningOfSentenceCase(item.name)!), Text(toBeginningOfSentenceCase(item.name)!),
], ],
@ -254,6 +252,7 @@ class _FriendsListState extends State<FriendsList> {
friends.sort((a, b) => a.username.length.compareTo(b.username.length)); friends.sort((a, b) => a.username.length.compareTo(b.username.length));
} }
return ListView.builder( return ListView.builder(
physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
itemCount: friends.length, itemCount: friends.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final friend = friends[index]; final friend = friends[index];

View file

@ -13,14 +13,14 @@ class GenericAvatar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return imageUri.isEmpty ? CircleAvatar( return imageUri.isEmpty ? CircleAvatar(
radius: radius, radius: radius,
foregroundColor: foregroundColor, foregroundColor: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
backgroundColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(placeholderIcon, color: foregroundColor,), child: Icon(placeholderIcon, color: foregroundColor,),
) : CachedNetworkImage( ) : CachedNetworkImage(
imageBuilder: (context, imageProvider) { imageBuilder: (context, imageProvider) {
return CircleAvatar( return CircleAvatar(
foregroundImage: imageProvider, foregroundImage: imageProvider,
foregroundColor: foregroundColor, foregroundColor: Colors.transparent,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
radius: radius, radius: radius,
); );
@ -28,20 +28,20 @@ class GenericAvatar extends StatelessWidget {
imageUrl: imageUri, imageUrl: imageUri,
placeholder: (context, url) { placeholder: (context, url) {
return CircleAvatar( return CircleAvatar(
backgroundColor: Colors.white54, backgroundColor: Theme.of(context).colorScheme.primaryContainer,
foregroundColor: foregroundColor, foregroundColor: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
radius: radius, radius: radius,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: CircularProgressIndicator(color: foregroundColor, strokeWidth: 2), child: CircularProgressIndicator(color: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer, strokeWidth: 2),
), ),
); );
}, },
errorWidget: (context, error, what) => CircleAvatar( errorWidget: (context, error, what) => CircleAvatar(
radius: radius, radius: radius,
foregroundColor: foregroundColor, foregroundColor: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
backgroundColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(placeholderIcon, color: foregroundColor,), child: Icon(placeholderIcon, color: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer,),
), ),
); );
} }

View file

@ -19,12 +19,7 @@ class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final TextEditingController _totpController = TextEditingController(); final TextEditingController _totpController = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
late final Future<AuthenticationData> _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async {
if (value.isAuthenticated) {
await loginSuccessful(value);
}
return value;
});
late final FocusNode _passwordFocusNode; late final FocusNode _passwordFocusNode;
late final FocusNode _totpFocusNode; late final FocusNode _totpFocusNode;
@ -150,102 +145,94 @@ class _LoginScreenState extends State<LoginScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text("Contacts++"), title: const Text("Contacts++"),
), ),
body: FutureBuilder( body: Builder(
future: _cachedLoginFuture, builder: (context) {
builder: (context, snapshot) { return ListView(
if (snapshot.hasData || snapshot.hasError) { controller: _scrollController,
final authData = snapshot.data; children: [
if (authData?.isAuthenticated ?? false) { Padding(
return const SizedBox.shrink(); padding: const EdgeInsets.symmetric(vertical: 64),
} child: Center(
return ListView( child: Text("Sign In", style: Theme
controller: _scrollController, .of(context)
children: [ .textTheme
Padding( .headlineMedium),
padding: const EdgeInsets.symmetric(vertical: 64), ),
child: Center( ),
child: Text("Sign In", style: Theme Padding(
.of(context) padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
.textTheme child: TextField(
.headlineMedium), autofocus: true,
controller: _usernameController,
onEditingComplete: () => _passwordFocusNode.requestFocus(),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
),
labelText: 'Username',
), ),
), ),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
child: TextField(
controller: _passwordController,
focusNode: _passwordFocusNode,
onEditingComplete: submit,
obscureText: true,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32)
),
labelText: 'Password',
),
),
),
if (_needsTotp)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
child: TextField( child: TextField(
autofocus: true, controller: _totpController,
controller: _usernameController, focusNode: _totpFocusNode,
onEditingComplete: () => _passwordFocusNode.requestFocus(), onEditingComplete: submit,
obscureText: false,
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
), ),
labelText: 'Username', labelText: '2FA Code',
), ),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), padding: const EdgeInsets.only(top: 16),
child: TextField( child: _isLoading ?
controller: _passwordController, const Center(child: CircularProgressIndicator()) :
focusNode: _passwordFocusNode, TextButton.icon(
onEditingComplete: submit, onPressed: submit,
obscureText: true, icon: const Icon(Icons.login),
decoration: InputDecoration( label: const Text("Login"),
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32)
),
labelText: 'Password',
),
),
), ),
if (_needsTotp) ),
Padding( Center(
child: AnimatedOpacity(
opacity: _errorOpacity,
duration: const Duration(milliseconds: 200),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
child: TextField( child: Text(_error, style: Theme
controller: _totpController, .of(context)
focusNode: _totpFocusNode, .textTheme
onEditingComplete: submit, .labelMedium
obscureText: false, ?.copyWith(color: Colors.red)),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
),
labelText: '2FA Code',
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: _isLoading ?
const Center(child: CircularProgressIndicator()) :
TextButton.icon(
onPressed: submit,
icon: const Icon(Icons.login),
label: const Text("Login"),
), ),
), ),
Center( )
child: AnimatedOpacity( ],
opacity: _errorOpacity, );
duration: const Duration(milliseconds: 200),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
child: Text(_error, style: Theme
.of(context)
.textTheme
.labelMedium
?.copyWith(color: Colors.red)),
),
),
)
],
);
}
return const LinearProgressIndicator();
} }
), ),
); );

View file

@ -0,0 +1,63 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
class CameraImageView extends StatelessWidget {
const CameraImageView({required this.file, super.key});
final File file;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Stack(
children: [
PhotoView(
imageProvider: FileImage(
file,
),
initialScale: PhotoViewComputedScale.covered,
minScale: PhotoViewComputedScale.contained,
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
onPressed: () {
Navigator.of(context).pop(false);
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
backgroundColor: Theme.of(context).colorScheme.surface,
side: BorderSide(width: 1, color: Theme.of(context).colorScheme.error)
),
icon: const Icon(Icons.close),
label: const Text("Cancel",),
),
TextButton.icon(
onPressed: () {
Navigator.of(context).pop(true);
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
backgroundColor: Theme.of(context).colorScheme.surface,
side: BorderSide(width: 1, color: Theme.of(context).colorScheme.primary)
),
icon: const Icon(Icons.check),
label: const Text("Okay"),
)
],
),
),
),
],
),
);
}
}

View file

@ -20,38 +20,46 @@ class MessageAsset extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final content = jsonDecode(message.content); final content = jsonDecode(message.content);
PhotoAsset? photoAsset;
try {
photoAsset = PhotoAsset.fromTags((content["tags"] as List).map((e) => "$e").toList());
} catch (_) {}
final formattedName = FormatNode.fromText(content["name"]); final formattedName = FormatNode.fromText(content["name"]);
return Container( return Container(
constraints: const BoxConstraints(maxWidth: 300), constraints: const BoxConstraints(maxWidth: 300),
child: Column( child: Column(
children: [ children: [
CachedNetworkImage( SizedBox(
imageUrl: Aux.neosDbToHttp(content["thumbnailUri"]), height: 256,
imageBuilder: (context, image) { width: double.infinity,
return InkWell( child: CachedNetworkImage(
onTap: () async { imageUrl: Aux.neosDbToHttp(content["thumbnailUri"]),
await Navigator.push( imageBuilder: (context, image) {
context, MaterialPageRoute(builder: (context) => return InkWell(
PhotoView( onTap: () async {
minScale: PhotoViewComputedScale.contained, PhotoAsset? photoAsset;
imageProvider: photoAsset == null try {
? image photoAsset = PhotoAsset.fromTags((content["tags"] as List).map((e) => "$e").toList());
: CachedNetworkImageProvider(Aux.neosDbToHttp(photoAsset.imageUri)), } catch (_) {}
heroAttributes: PhotoViewHeroAttributes(tag: message.id), await Navigator.push(
), context, MaterialPageRoute(builder: (context) =>
),); PhotoView(
}, minScale: PhotoViewComputedScale.contained,
child: Hero( imageProvider: photoAsset == null
tag: message.id, ? image
child: ClipRRect(borderRadius: BorderRadius.circular(16), child: Image(image: image,)), : CachedNetworkImageProvider(Aux.neosDbToHttp(photoAsset.imageUri)),
), heroAttributes: PhotoViewHeroAttributes(tag: message.id),
); ),
}, ),);
placeholder: (context, uri) => const CircularProgressIndicator(), },
child: Hero(
tag: message.id,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image(image: image, fit: BoxFit.cover,),
),
),
);
},
errorWidget: (context, url, error) => const Icon(Icons.broken_image, size: 64,),
placeholder: (context, uri) => const Center(child: CircularProgressIndicator()),
),
), ),
const SizedBox(height: 8,), const SizedBox(height: 8,),
Row( Row(

View file

@ -0,0 +1,304 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart';
class MessageAttachmentList extends StatefulWidget {
const MessageAttachmentList({required this.onChange, required this.disabled, this.initialFiles, super.key});
final List<(FileType, File)>? initialFiles;
final Function(List<(FileType, File)> files) onChange;
final bool disabled;
@override
State<MessageAttachmentList> createState() => _MessageAttachmentListState();
}
class _MessageAttachmentListState extends State<MessageAttachmentList> {
final List<(FileType, File)> _loadedFiles = [];
final ScrollController _scrollController = ScrollController();
bool _showShadow = true;
bool _popupIsOpen = false;
@override
void initState() {
super.initState();
_loadedFiles.clear();
_loadedFiles.addAll(widget.initialFiles ?? []);
_scrollController.addListener(() {
if (_scrollController.position.maxScrollExtent > 0 && !_showShadow) {
setState(() {
_showShadow = true;
});
}
if (_scrollController.position.atEdge && _scrollController.position.pixels > 0
&& _showShadow) {
setState(() {
_showShadow = false;
});
}
});
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Colors.transparent, Colors.transparent, Colors.transparent, Theme
.of(context)
.colorScheme
.background
],
stops: [0.0, 0.0, _showShadow ? 0.90 : 1.0, 1.0], // 10% purple, 80% transparent, 10% purple
).createShader(bounds);
},
blendMode: BlendMode.dstOut,
child: SingleChildScrollView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
child: Row(
children: _loadedFiles.map((file) =>
Padding(
padding: const EdgeInsets.only(left: 4.0, right: 4.0, top: 4.0),
child: TextButton.icon(
onPressed: widget.disabled ? null : () {
showDialog(context: context, builder: (context) =>
AlertDialog(
title: const Text("Remove attachment"),
content: Text(
"This will remove attachment '${basename(
file.$2.path)}', are you sure?"),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text("No"),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
setState(() {
_loadedFiles.remove(file);
});
await widget.onChange(_loadedFiles);
},
child: const Text("Yes"),
)
],
),
);
},
style: TextButton.styleFrom(
foregroundColor: Theme
.of(context)
.colorScheme
.onBackground,
side: BorderSide(
color: Theme
.of(context)
.colorScheme
.primary,
width: 1
),
),
label: Text(basename(file.$2.path)),
icon: switch (file.$1) {
FileType.image => const Icon(Icons.image),
_ => const Icon(Icons.attach_file)
}
),
),
).toList()
),
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.decelerate,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axis: Axis.horizontal,
//position: Tween<Offset>(begin: const Offset(1, 0), end: const Offset(0, 0)).animate(animation),
child: child,
),
),
child: _popupIsOpen ? Row(
key: const ValueKey("popup-buttons"),
children: [
IconButton(
iconSize: 24,
style: IconButton.styleFrom(
backgroundColor: Theme
.of(context)
.colorScheme
.surface,
foregroundColor: Theme
.of(context)
.colorScheme
.onSurface,
side: BorderSide(
width: 1,
color: Theme
.of(context)
.colorScheme
.secondary,
)
),
padding: EdgeInsets.zero,
onPressed: () async {
final result = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true);
if (result != null) {
setState(() {
_loadedFiles.addAll(
result.files.map((e) => e.path != null ? (FileType.image, File(e.path!)) : null)
.whereNotNull());
});
}
},
icon: const Icon(Icons.image,),
),
IconButton(
iconSize: 24,
style: IconButton.styleFrom(
backgroundColor: Theme
.of(context)
.colorScheme
.surface,
foregroundColor: Theme
.of(context)
.colorScheme
.onSurface,
side: BorderSide(
width: 1,
color: Theme
.of(context)
.colorScheme
.secondary,
)
),
padding: EdgeInsets.zero,
onPressed: () async {
final picture = await ImagePicker().pickImage(source: ImageSource.camera);
if (picture != null) {
final file = File(picture.path);
if (await file.exists()) {
setState(() {
_loadedFiles.add((FileType.image, file));
});
await widget.onChange(_loadedFiles);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to load image file")));
}
}
}
},
icon: const Icon(Icons.camera,),
),
IconButton(
iconSize: 24,
style: IconButton.styleFrom(
backgroundColor: Theme
.of(context)
.colorScheme
.surface,
foregroundColor: Theme
.of(context)
.colorScheme
.onSurface,
side: BorderSide(
width: 1,
color: Theme
.of(context)
.colorScheme
.secondary,
)
),
padding: EdgeInsets.zero,
onPressed: () async {
final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: true);
if (result != null) {
setState(() {
_loadedFiles.addAll(
result.files.map((e) => e.path != null ? (FileType.any, File(e.path!)) : null)
.whereNotNull());
});
}
},
icon: const Icon(Icons.file_present_rounded,),
),
],
) : const SizedBox.shrink(),
),
Container(
color: Theme.of(context).colorScheme.surface,
child: IconButton(onPressed: () {
setState(() {
_popupIsOpen = !_popupIsOpen;
});
}, icon: AnimatedRotation(
duration: const Duration(milliseconds: 200),
turns: _popupIsOpen ? 3/8 : 0,
child: const Icon(Icons.add),
)),
)
],
);
}
}
enum DocumentType {
gallery,
camera,
rawFile;
}
class PopupMenuIcon<T> extends PopupMenuEntry<T> {
const PopupMenuIcon({this.radius=24, this.value, required this.icon, this.onPressed, super.key});
final T? value;
final double radius;
final Widget icon;
final void Function()? onPressed;
@override
State<StatefulWidget> createState() => _PopupMenuIconState();
@override
double get height => radius;
@override
bool represents(T? value) => this.value == value;
}
class _PopupMenuIconState extends State<PopupMenuIcon> {
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(128),
child: Container(
color: Theme.of(context).colorScheme.surface,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
margin: const EdgeInsets.all(1),
child: InkWell(
child: widget.icon,
),
),
);
}
}

View file

@ -2,11 +2,12 @@ import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/clients/audio_cache_client.dart';
import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart'; import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:provider/provider.dart';
class MessageAudioPlayer extends StatefulWidget { class MessageAudioPlayer extends StatefulWidget {
const MessageAudioPlayer({required this.message, this.foregroundColor, super.key}); const MessageAudioPlayer({required this.message, this.foregroundColor, super.key});
@ -18,44 +19,76 @@ class MessageAudioPlayer extends StatefulWidget {
State<MessageAudioPlayer> createState() => _MessageAudioPlayerState(); State<MessageAudioPlayer> createState() => _MessageAudioPlayerState();
} }
class _MessageAudioPlayerState extends State<MessageAudioPlayer> { class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBindingObserver {
final AudioPlayer _audioPlayer = AudioPlayer(); final AudioPlayer _audioPlayer = AudioPlayer();
Future? _audioFileFuture;
double _sliderValue = 0; double _sliderValue = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (Platform.isAndroid) { WidgetsBinding.instance.addObserver(this);
_audioPlayer.setUrl( }
Aux.neosDbToHttp(AudioClipContent
.fromMap(jsonDecode(widget.message.content)) @override
.assetUri), void didChangeAppLifecycleState(AppLifecycleState state) {
preload: true).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); if (state == AppLifecycleState.paused) {
_audioPlayer.stop();
} }
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
final audioCache = Provider.of<AudioCacheClient>(context);
_audioFileFuture = audioCache
.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content)))
.then((value) => _audioPlayer.setFilePath(value.path))
.whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off));
}
@override
void didUpdateWidget(covariant MessageAudioPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.message.id == widget.message.id) return;
final audioCache = Provider.of<AudioCacheClient>(context);
_audioFileFuture = audioCache
.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content)))
.then((value) async {
final path = _audioPlayer.setFilePath(value.path);
await _audioPlayer.setLoopMode(LoopMode.off);
await _audioPlayer.pause();
await _audioPlayer.seek(Duration.zero);
return path;
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_audioPlayer.dispose().onError((error, stackTrace) {});
super.dispose();
}
Widget _createErrorWidget(String error) { Widget _createErrorWidget(String error) {
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon(Icons.error_outline, color: Theme Icon(
.of(context) Icons.error_outline,
.colorScheme color: Theme.of(context).colorScheme.error,
.error,), ),
const SizedBox(height: 4,), const SizedBox(
Text(error, textAlign: TextAlign.center, height: 4,
),
Text(
error,
textAlign: TextAlign.center,
softWrap: true, softWrap: true,
maxLines: 3, maxLines: 3,
style: Theme style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error),
.of(context)
.textTheme
.bodySmall
?.copyWith(color: Theme
.of(context)
.colorScheme
.error),
), ),
], ],
), ),
@ -67,115 +100,136 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform."); return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform.");
} }
return IntrinsicWidth( return IntrinsicWidth(
child: StreamBuilder<PlayerState>( child: StreamBuilder<PlayerState>(
stream: _audioPlayer.playerStateStream, stream: _audioPlayer.playerStateStream,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasError) {
final playerState = snapshot.data as PlayerState; FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
return Column( return _createErrorWidget("Failed to load audio-message.");
crossAxisAlignment: CrossAxisAlignment.center, }
mainAxisAlignment: MainAxisAlignment.center, final playerState = snapshot.data;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( FutureBuilder(
mainAxisSize: MainAxisSize.max, future: _audioFileFuture,
mainAxisAlignment: MainAxisAlignment.spaceBetween, builder: (context, fileSnapshot) {
children: [ if (fileSnapshot.hasError) {
IconButton( return const IconButton(
onPressed: () { icon: Icon(Icons.warning),
switch (playerState.processingState) { onPressed: null,
case ProcessingState.idle: );
case ProcessingState.loading: }
case ProcessingState.buffering: return IconButton(
break; onPressed: fileSnapshot.hasData &&
case ProcessingState.ready: snapshot.hasData &&
if (playerState.playing) { playerState != null &&
_audioPlayer.pause(); playerState.processingState != ProcessingState.loading
} else { ? () {
_audioPlayer.play(); switch (playerState.processingState) {
} case ProcessingState.idle:
break; case ProcessingState.loading:
case ProcessingState.completed: case ProcessingState.buffering:
_audioPlayer.seek(Duration.zero); break;
_audioPlayer.play(); case ProcessingState.ready:
break; if (playerState.playing) {
} _audioPlayer.pause();
}, } else {
color: widget.foregroundColor, _audioPlayer.play();
icon: SizedBox( }
width: 24, break;
height: 24, case ProcessingState.completed:
child: playerState.processingState == ProcessingState.loading _audioPlayer.seek(Duration.zero);
? const Center(child: CircularProgressIndicator(),) _audioPlayer.play();
: Icon(((_audioPlayer.duration ?? Duration.zero) - _audioPlayer.position).inMilliseconds < break;
10 ? Icons.replay
: (playerState.playing ? Icons.pause : Icons.play_arrow)),
),
),
StreamBuilder(
stream: _audioPlayer.positionStream,
builder: (context, snapshot) {
_sliderValue = (_audioPlayer.position.inMilliseconds /
(_audioPlayer.duration?.inMilliseconds ?? 0)).clamp(0, 1);
return StatefulBuilder( // Not sure if this makes sense here...
builder: (context, setState) {
return SliderTheme(
data: SliderThemeData(
inactiveTrackColor: widget.foregroundColor?.withAlpha(100),
),
child: Slider(
thumbColor: widget.foregroundColor,
value: _sliderValue,
min: 0.0,
max: 1.0,
onChanged: (value) async {
_audioPlayer.pause();
setState(() {
_sliderValue = value;
});
_audioPlayer.seek(Duration(
milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(),
));
},
),
);
} }
); }
} : null,
) color: widget.foregroundColor,
], icon: Icon(
((_audioPlayer.duration ?? const Duration(days: 9999)) - _audioPlayer.position)
.inMilliseconds <
10
? Icons.replay
: ((playerState?.playing ?? false) ? Icons.pause : Icons.play_arrow),
),
);
},
), ),
Row( StreamBuilder(
mainAxisSize: MainAxisSize.max, stream: _audioPlayer.positionStream,
mainAxisAlignment: MainAxisAlignment.spaceEvenly, builder: (context, snapshot) {
children: [ _sliderValue = _audioPlayer.duration == null
const SizedBox(width: 4,), ? 0
StreamBuilder( : (_audioPlayer.position.inMilliseconds / (_audioPlayer.duration!.inMilliseconds))
stream: _audioPlayer.positionStream, .clamp(0, 1);
builder: (context, snapshot) { return StatefulBuilder(
return Text("${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? // Not sure if this makes sense here...
"??"}", builder: (context, setState) {
style: Theme return SliderTheme(
.of(context) data: SliderThemeData(
.textTheme inactiveTrackColor: widget.foregroundColor?.withAlpha(100),
.bodySmall ),
?.copyWith(color: widget.foregroundColor?.withAlpha(150)), child: Slider(
); thumbColor: widget.foregroundColor,
} value: _sliderValue,
), min: 0.0,
const Spacer(), max: 1.0,
MessageStateIndicator(message: widget.message, foregroundColor: widget.foregroundColor,), onChanged: (value) async {
], _audioPlayer.pause();
setState(() {
_sliderValue = value;
});
_audioPlayer.seek(
Duration(
milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(),
),
);
},
),
);
},
);
},
) )
], ],
); ),
} else if (snapshot.hasError) { Row(
FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); mainAxisSize: MainAxisSize.max,
return _createErrorWidget("Failed to load audio-message."); mainAxisAlignment: MainAxisAlignment.spaceEvenly,
} else { children: [
return const Center(child: CircularProgressIndicator(),); const SizedBox(
} width: 4,
} ),
StreamBuilder(
stream: _audioPlayer.positionStream,
builder: (context, snapshot) {
return Text(
"${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? "??"}",
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: widget.foregroundColor?.withAlpha(150)),
);
},
),
const Spacer(),
MessageStateIndicator(
message: widget.message,
foregroundColor: widget.foregroundColor,
),
],
),
],
);
},
), ),
); );
} }

View file

@ -29,8 +29,7 @@ class MessageBubble extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: switch (message.type) { child: switch (message.type) {
MessageType.sessionInvite => MessageType.sessionInvite => MessageSessionInvite(message: message, foregroundColor: foregroundColor,),
MessageSessionInvite(message: message, foregroundColor: foregroundColor,),
MessageType.object => MessageAsset(message: message, foregroundColor: foregroundColor,), MessageType.object => MessageAsset(message: message, foregroundColor: foregroundColor,),
MessageType.sound => MessageAudioPlayer(message: message, foregroundColor: foregroundColor,), MessageType.sound => MessageAudioPlayer(message: message, foregroundColor: foregroundColor,),
MessageType.unknown || MessageType.text => MessageText(message: message, foregroundColor: foregroundColor,) MessageType.unknown || MessageType.text => MessageText(message: message, foregroundColor: foregroundColor,)

View file

@ -0,0 +1,570 @@
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:contacts_plus_plus/apis/record_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/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/widgets/messages/message_attachment_list.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:record/record.dart';
import 'package:uuid/uuid.dart';
class MessageInputBar extends StatefulWidget {
const MessageInputBar({this.disabled=false, required this.recipient, this.onMessageSent, super.key});
final bool disabled;
final Friend recipient;
final Function()? onMessageSent;
@override
State<StatefulWidget> createState() => _MessageInputBarState();
}
class _MessageInputBarState extends State<MessageInputBar> {
final TextEditingController _messageTextController = TextEditingController();
final List<(FileType, File)> _loadedFiles = [];
final Record _recorder = Record();
final ImagePicker _imagePicker = ImagePicker();
DateTime? _recordingStartTime;
bool _isSending = false;
bool _attachmentPickerOpen = false;
String _currentText = "";
double? _sendProgress;
bool get _isRecording => _recordingStartTime != null;
set _isRecording(value) => _recordingStartTime = value ? DateTime.now() : null;
bool _recordingCancelled = false;
@override
void dispose() {
_recorder.dispose();
_messageTextController.dispose();
super.dispose();
}
Future<void> sendTextMessage(ApiClient client, MessagingClient mClient, String content) async {
if (content.isEmpty) return;
final message = Message(
id: Message.generateId(),
recipientId: widget.recipient.id,
senderId: client.userId,
type: MessageType.text,
content: content,
sendTime: DateTime.now().toUtc(),
state: MessageState.local,
);
mClient.sendMessage(message);
}
Future<void> sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId,
void Function(double progress) progressCallback) async {
final record = await RecordApi.uploadImage(
client,
image: file,
machineId: machineId,
progressCallback: progressCallback,
);
final message = Message(
id: record.extractMessageId() ?? Message.generateId(),
recipientId: widget.recipient.id,
senderId: client.userId,
type: MessageType.object,
content: jsonEncode(record.toMap()),
sendTime: DateTime.now().toUtc(),
state: MessageState.local
);
mClient.sendMessage(message);
}
Future<void> sendVoiceMessage(ApiClient client, MessagingClient mClient, File file, String machineId,
void Function(double progress) progressCallback) async {
final record = await RecordApi.uploadVoiceClip(
client,
voiceClip: file,
machineId: machineId,
progressCallback: progressCallback,
);
final message = Message(
id: record.extractMessageId() ?? Message.generateId(),
recipientId: widget.recipient.id,
senderId: client.userId,
type: MessageType.sound,
content: jsonEncode(record.toMap()),
sendTime: DateTime.now().toUtc(),
state: MessageState.local,
);
mClient.sendMessage(message);
}
Future<void> sendRawFileMessage(ApiClient client, MessagingClient mClient, File file, String machineId,
void Function(double progress) progressCallback) async {
final record = await RecordApi.uploadRawFile(
client,
file: file,
machineId: machineId,
progressCallback: progressCallback,
);
final message = Message(
id: record.extractMessageId() ?? Message.generateId(),
recipientId: widget.recipient.id,
senderId: client.userId,
type: MessageType.object,
content: jsonEncode(record.toMap()),
sendTime: DateTime.now().toUtc(),
state: MessageState.local,
);
mClient.sendMessage(message);
}
void _pointerMoveEventHandler(PointerMoveEvent event) {
if (!_isRecording) return;
final width = MediaQuery.of(context).size.width;
if (event.localPosition.dx < width - width/4) {
if (!_recordingCancelled) {
HapticFeedback.vibrate();
setState(() {
_recordingCancelled = true;
});
}
} else {
if (_recordingCancelled) {
HapticFeedback.vibrate();
setState(() {
_recordingCancelled = false;
});
}
}
}
Stream<Duration> _recordingDurationStream() async* {
while (_isRecording) {
yield DateTime.now().difference(_recordingStartTime!);
await Future.delayed(const Duration(milliseconds: 100));
}
}
@override
Widget build(BuildContext context) {
final mClient = Provider.of<MessagingClient>(context, listen: false);
return Listener(
onPointerMove: _pointerMoveEventHandler,
onPointerUp: (_) async {
// Do this here as the pointerUp event of the gesture detector on the mic button can be unreliable
final cHolder = ClientHolder.of(context);
if (_isRecording) {
if (_recordingCancelled) {
setState(() {
_isRecording = false;
});
final recording = await _recorder.stop();
if (recording == null) return;
final file = File(recording);
if (await file.exists()) {
await file.delete();
}
}
setState(() {
_recordingCancelled = false;
_isRecording = false;
});
if (await _recorder.isRecording()) {
final recording = await _recorder.stop();
if (recording == null) return;
final file = File(recording);
setState(() {
_isSending = true;
_sendProgress = 0;
});
final apiClient = cHolder.apiClient;
await sendVoiceMessage(
apiClient,
mClient,
file,
cHolder.settingsClient.currentSettings.machineId.valueOrDefault,
(progress) {
setState(() {
_sendProgress = progress;
});
}
);
setState(() {
_isSending = false;
_sendProgress = null;
});
}
}
},
child: Container(
decoration: BoxDecoration(
border: const Border(top: BorderSide(width: 1, color: Colors.black)),
color: Theme
.of(context)
.colorScheme
.background,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
children: [
if (_isSending && _sendProgress != null)
LinearProgressIndicator(value: _sendProgress),
Container(
decoration: BoxDecoration(
color: Theme
.of(context)
.colorScheme
.background,
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
transitionBuilder: (Widget child, animation) =>
SizeTransition(sizeFactor: animation, child: child,),
child: switch ((_attachmentPickerOpen, _loadedFiles)) {
(true, []) =>
Row(
key: const ValueKey("attachment-picker"),
children: [
TextButton.icon(
onPressed: _isSending ? null : () async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image, allowMultiple: true);
if (result != null) {
setState(() {
_loadedFiles.addAll(
result.files.map((e) =>
e.path != null ? (FileType.image, File(e.path!)) : null)
.whereNotNull());
});
}
},
icon: const Icon(Icons.image),
label: const Text("Gallery"),
),
TextButton.icon(
onPressed: _isSending ? null : () async {
final picture = await _imagePicker.pickImage(source: ImageSource.camera);
if (picture == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to get image path")));
}
return;
}
final file = File(picture.path);
if (await file.exists()) {
setState(() {
_loadedFiles.add((FileType.image, file));
});
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to load image file")));
}
}
},
icon: const Icon(Icons.camera),
label: const Text("Camera"),
),
TextButton.icon(
onPressed: _isSending ? null : () async {
final result = await FilePicker.platform.pickFiles(
type: FileType.any, allowMultiple: true);
if (result != null) {
setState(() {
_loadedFiles.addAll(
result.files.map((e) =>
e.path != null ? (FileType.any, File(e.path!)) : null)
.whereNotNull());
});
}
},
icon: const Icon(Icons.file_present_rounded),
label: const Text("Document"),
),
],
),
(false, []) => null,
(_, _) =>
MessageAttachmentList(
disabled: _isSending,
initialFiles: _loadedFiles,
onChange: (List<(FileType, File)> loadedFiles) => setState(() {
_loadedFiles.clear();
_loadedFiles.addAll(loadedFiles);
}),
),
},
),
),
Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) =>
FadeTransition(
opacity: animation,
child: RotationTransition(
turns: Tween<double>(begin: 0.6, end: 1).animate(animation),
child: child,
),
),
child: switch((_attachmentPickerOpen, _isRecording)) {
(_, true) => IconButton(
onPressed: () {
},
icon: Icon(Icons.delete, color: _recordingCancelled ? Theme.of(context).colorScheme.error : null,),
),
(false, _) => IconButton(
key: const ValueKey("add-attachment-icon"),
onPressed: _isSending ? null : () {
setState(() {
_attachmentPickerOpen = true;
});
},
icon: const Icon(Icons.attach_file,),
),
(true, _) => IconButton(
key: const ValueKey("remove-attachment-icon"),
onPressed: _isSending ? null : () async {
if (_loadedFiles.isNotEmpty) {
await showDialog(context: context, builder: (context) =>
AlertDialog(
title: const Text("Remove all attachments"),
content: const Text("This will remove all attachments, are you sure?"),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text("No"),
),
TextButton(
onPressed: () {
setState(() {
_loadedFiles.clear();
_attachmentPickerOpen = false;
});
Navigator.of(context).pop();
},
child: const Text("Yes"),
)
],
));
} else {
setState(() {
_attachmentPickerOpen = false;
});
}
},
icon: const Icon(Icons.close,),
),
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Stack(
children: [
TextField(
enabled: (!widget.disabled) && !_isSending,
autocorrect: true,
controller: _messageTextController,
showCursor: !_isRecording,
maxLines: 4,
minLines: 1,
onChanged: (text) {
if (text.isEmpty != _currentText.isEmpty) {
setState(() {
_currentText = text;
});
return;
}
_currentText = text;
},
style: Theme.of(context).textTheme.bodyLarge,
decoration: InputDecoration(
isDense: true,
hintText: _isRecording ? "" : "Message ${widget.recipient
.username}...",
hintMaxLines: 1,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
fillColor: Colors.black26,
filled: true,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(24),
)
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) =>
FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, .2),
end: const Offset(0, 0),
).animate(animation),
child: child,
),
),
child: _isRecording ? Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _recordingCancelled ? Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(width: 8,),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Icon(Icons.cancel, color: Colors.red, size: 16,),
),
Text("Cancel Recording", style: Theme.of(context).textTheme.titleMedium),
],
) : Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(width: 8,),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Icon(Icons.circle, color: Colors.red, size: 16,),
),
StreamBuilder<Duration>(
stream: _recordingDurationStream(),
builder: (context, snapshot) {
return Text("Recording: ${snapshot.data?.format()}", style: Theme.of(context).textTheme.titleMedium);
}
),
],
),
) : const SizedBox.shrink(),
),
],
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) =>
FadeTransition(opacity: animation, child: RotationTransition(
turns: Tween<double>(begin: 0.5, end: 1).animate(animation), child: child,),),
child: _currentText.isNotEmpty || _loadedFiles.isNotEmpty ? IconButton(
key: const ValueKey("send-button"),
splashRadius: 24,
padding: EdgeInsets.zero,
onPressed: _isSending ? null : () async {
final cHolder = ClientHolder.of(context);
final sMsgnr = ScaffoldMessenger.of(context);
final settings = cHolder.settingsClient.currentSettings;
final toSend = List<(FileType, File)>.from(_loadedFiles);
setState(() {
_isSending = true;
_sendProgress = 0;
_attachmentPickerOpen = false;
_loadedFiles.clear();
});
try {
for (int i = 0; i < toSend.length; i++) {
final totalProgress = i / toSend.length;
final file = toSend[i];
if (file.$1 == FileType.image) {
await sendImageMessage(
cHolder.apiClient, mClient, file.$2, settings.machineId.valueOrDefault,
(progress) =>
setState(() {
_sendProgress = totalProgress + progress * 1 / toSend.length;
}),
);
} else {
await sendRawFileMessage(
cHolder.apiClient, mClient, file.$2, settings.machineId.valueOrDefault, (progress) =>
setState(() =>
_sendProgress = totalProgress + progress * 1 / toSend.length));
}
}
setState(() {
_sendProgress = null;
});
if (_currentText.isNotEmpty) {
await sendTextMessage(cHolder.apiClient, mClient, _messageTextController.text);
}
_messageTextController.clear();
_currentText = "";
_loadedFiles.clear();
_attachmentPickerOpen = false;
} catch (e, s) {
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
sMsgnr.showSnackBar(SnackBar(content: Text("Failed to send a message: $e")));
}
setState(() {
_isSending = false;
_sendProgress = null;
});
widget.onMessageSent?.call();
},
icon: const Icon(Icons.send),
) : GestureDetector(
onTapUp: (_) {
_recordingCancelled = true;
},
onTapDown: widget.disabled ? null : (_) async {
HapticFeedback.vibrate();
final hadToAsk = await Permission.microphone.isDenied;
final hasPermission = !await _recorder.hasPermission();
if (hasPermission) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("No permission to record audio."),
));
}
return;
}
if (hadToAsk) {
// We had to ask for permissions so the user removed their finger from the record button.
return;
}
final dir = await getTemporaryDirectory();
await _recorder.start(
path: "${dir.path}/A-${const Uuid().v4()}.wav",
encoder: AudioEncoder.wav,
samplingRate: 44100
);
setState(() {
_isRecording = true;
});
},
child: IconButton(
icon: const Icon(Icons.mic_outlined),
onPressed: _isSending ? null : () {
// Empty onPressed for that sweet sweet ripple effect
},
),
),
),
],
),
],
),
),
);
}
}

View file

@ -1,9 +1,9 @@
import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/clients/audio_cache_client.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; import 'package:contacts_plus_plus/widgets/default_error_widget.dart';
import 'package:contacts_plus_plus/widgets/friends/friend_online_status_indicator.dart'; import 'package:contacts_plus_plus/widgets/friends/friend_online_status_indicator.dart';
import 'package:contacts_plus_plus/widgets/messages/message_input_bar.dart';
import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -20,20 +20,15 @@ class MessagesList extends StatefulWidget {
} }
class _MessagesListState extends State<MessagesList> with SingleTickerProviderStateMixin { class _MessagesListState extends State<MessagesList> with SingleTickerProviderStateMixin {
final TextEditingController _messageTextController = TextEditingController();
final ScrollController _sessionListScrollController = ScrollController(); final ScrollController _sessionListScrollController = ScrollController();
final ScrollController _messageScrollController = ScrollController();
bool _isSendable = false;
bool _showSessionListScrollChevron = false; bool _showSessionListScrollChevron = false;
bool _showBottomBarShadow = false; bool _sessionListOpen = false;
double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0; double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0;
@override @override
void dispose() { void dispose() {
_messageTextController.dispose();
_sessionListScrollController.dispose(); _sessionListScrollController.dispose();
super.dispose(); super.dispose();
} }
@ -47,262 +42,204 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
_showSessionListScrollChevron = true; _showSessionListScrollChevron = true;
}); });
} }
if (_sessionListScrollController.position.atEdge && _sessionListScrollController.position.pixels > 0 if (_sessionListScrollController.position.atEdge &&
&& _showSessionListScrollChevron) { _sessionListScrollController.position.pixels > 0 &&
_showSessionListScrollChevron) {
setState(() { setState(() {
_showSessionListScrollChevron = false; _showSessionListScrollChevron = false;
}); });
} }
}); });
_messageScrollController.addListener(() {
if (!_messageScrollController.hasClients) return;
if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels == 0 &&
_showBottomBarShadow) {
setState(() {
_showBottomBarShadow = false;
});
} else if (!_showBottomBarShadow) {
setState(() {
_showBottomBarShadow = true;
});
}
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final apiClient = ClientHolder final sessions = widget.friend.userStatus.activeSessions;
.of(context) final appBarColor = Theme.of(context).colorScheme.surfaceVariant;
.apiClient; return Consumer<MessagingClient>(builder: (context, mClient, _) {
var sessions = widget.friend.userStatus.activeSessions; final cache = mClient.getUserMessageCache(widget.friend.id);
final appBarColor = Theme return Scaffold(
.of(context) appBar: AppBar(
.colorScheme title: Row(
.surfaceVariant; crossAxisAlignment: CrossAxisAlignment.center,
return Consumer<MessagingClient>(
builder: (context, mClient, _) {
final cache = mClient.getUserMessageCache(widget.friend.id);
return Scaffold(
appBar: AppBar(
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus),
const SizedBox(width: 8,),
Text(widget.friend.username),
if (widget.friend.isHeadless) Padding(
padding: const EdgeInsets.only(left: 12),
child: Icon(Icons.dns, size: 18, color: Theme
.of(context)
.colorScheme
.onSecondaryContainer
.withAlpha(150),),
),
],
),
scrolledUnderElevation: 0.0,
backgroundColor: appBarColor,
),
body: Column(
children: [ children: [
if (sessions.isNotEmpty) Container( FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus),
constraints: const BoxConstraints(maxHeight: 64), const SizedBox(
decoration: BoxDecoration( width: 8,
color: appBarColor, ),
border: const Border(top: BorderSide(width: 1, color: Colors.black26),) Text(widget.friend.username),
if (widget.friend.isHeadless)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Icon(
Icons.dns,
size: 18,
color: Theme.of(context).colorScheme.onSecondaryContainer.withAlpha(150),
),
), ),
child: Stack( ],
children: [ ),
ListView.builder( bottom: sessions.isNotEmpty && _sessionListOpen
controller: _sessionListScrollController, ? null
scrollDirection: Axis.horizontal, : PreferredSize(
itemCount: sessions.length, preferredSize: const Size.fromHeight(1),
itemBuilder: (context, index) => SessionTile(session: sessions[index]), child: Container(
), height: 1,
AnimatedOpacity( color: Colors.black,
opacity: _shevronOpacity, ),
curve: Curves.easeOut, ),
duration: const Duration(milliseconds: 200), actions: [
child: Align( if (sessions.isNotEmpty)
alignment: Alignment.centerRight, AnimatedRotation(
child: Container( turns: _sessionListOpen ? -1 / 4 : 1 / 4,
padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1), duration: const Duration(milliseconds: 200),
decoration: BoxDecoration( child: IconButton(
gradient: LinearGradient( onPressed: () {
begin: Alignment.centerLeft, setState(() {
end: Alignment.centerRight, _sessionListOpen = !_sessionListOpen;
colors: [ });
appBarColor.withOpacity(0), },
appBarColor, icon: const Icon(Icons.chevron_right),
appBarColor,
],
),
),
height: double.infinity,
child: const Icon(Icons.chevron_right),
),
),
)
],
), ),
), ),
Expanded( const SizedBox(
child: Builder( width: 4,
builder: (context) { )
if (cache == null) { ],
return const Column( scrolledUnderElevation: 0.0,
mainAxisAlignment: MainAxisAlignment.start, backgroundColor: appBarColor,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
),
body: Column(
children: [
if (sessions.isNotEmpty)
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) =>
SizeTransition(sizeFactor: animation, axis: Axis.vertical, child: child),
child: sessions.isEmpty || !_sessionListOpen
? null
: Container(
constraints: const BoxConstraints(maxHeight: 64),
decoration: BoxDecoration(
color: appBarColor,
border: const Border(
bottom: BorderSide(width: 1, color: Colors.black),
)),
child: Stack(
children: [ children: [
LinearProgressIndicator() ListView.builder(
], controller: _sessionListScrollController,
); scrollDirection: Axis.horizontal,
} itemCount: sessions.length,
if (cache.error != null) { itemBuilder: (context, index) => SessionTile(session: sessions[index]),
return DefaultErrorWidget( ),
message: cache.error.toString(), AnimatedOpacity(
onRetry: () { opacity: _shevronOpacity,
setState(() { curve: Curves.easeOut,
mClient.deleteUserMessageCache(widget.friend.id); duration: const Duration(milliseconds: 200),
}); child: Align(
mClient.loadUserMessageCache(widget.friend.id); alignment: Alignment.centerRight,
}, child: Container(
); padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1),
} decoration: BoxDecoration(
if (cache.messages.isEmpty) { gradient: LinearGradient(
return Center( begin: Alignment.centerLeft,
child: Column( end: Alignment.centerRight,
mainAxisAlignment: MainAxisAlignment.center, colors: [
children: [ appBarColor.withOpacity(0),
const Icon(Icons.message_outlined), appBarColor,
Padding( appBarColor,
padding: const EdgeInsets.symmetric(vertical: 24), ],
child: Text( ),
"There are no messages here\nWhy not say hello?", ),
textAlign: TextAlign.center, height: double.infinity,
style: Theme child: const Icon(Icons.chevron_right),
.of(context) ),
.textTheme
.titleMedium,
), ),
) )
], ],
), ),
); ),
}
return ListView.builder(
controller: _messageScrollController,
reverse: true,
itemCount: cache.messages.length,
itemBuilder: (context, index) {
final entry = cache.messages[index];
if (index == cache.messages.length - 1) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: MessageBubble(message: entry,),
);
}
return MessageBubble(message: entry,);
},
);
},
),
), ),
AnimatedContainer( Expanded(
decoration: BoxDecoration( child: Stack(
boxShadow: [ children: [
BoxShadow( Builder(
blurRadius: _showBottomBarShadow ? 8 : 0, builder: (context) {
color: Theme.of(context).shadowColor, if (cache == null) {
offset: const Offset(0, 4), return const Column(
), mainAxisAlignment: MainAxisAlignment.start,
], children: [LinearProgressIndicator()],
color: Theme.of(context).colorScheme.background, );
), }
padding: const EdgeInsets.symmetric(horizontal: 4), if (cache.error != null) {
duration: const Duration(milliseconds: 250), return DefaultErrorWidget(
child: Row( message: cache.error.toString(),
children: [ onRetry: () {
Expanded( setState(() {
child: Padding( mClient.deleteUserMessageCache(widget.friend.id);
padding: const EdgeInsets.all(8), });
child: TextField( mClient.loadUserMessageCache(widget.friend.id);
enabled: cache != null && cache.error == null,
autocorrect: true,
controller: _messageTextController,
maxLines: 4,
minLines: 1,
onChanged: (text) {
if (text.isNotEmpty && !_isSendable) {
setState(() {
_isSendable = true;
});
} else if (text.isEmpty && _isSendable) {
setState(() {
_isSendable = false;
});
}
}, },
decoration: InputDecoration( );
isDense: true, }
hintText: "Message ${widget.friend if (cache.messages.isEmpty) {
.username}...", return Center(
hintMaxLines: 1, child: Column(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), mainAxisAlignment: MainAxisAlignment.center,
border: OutlineInputBorder( children: [
borderRadius: BorderRadius.circular(24) const Icon(Icons.message_outlined),
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
"There are no messages here\nWhy not say hello?",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
) )
],
), ),
), );
), }
), return Provider(
Padding( create: (BuildContext context) => AudioCacheClient(),
padding: const EdgeInsets.only(left: 8, right: 4.0), child: ListView.builder(
child: Consumer<MessagingClient>( reverse: true,
builder: (context, mClient, _) { physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
return IconButton( itemCount: cache.messages.length,
splashRadius: 24, itemBuilder: (context, index) {
onPressed: _isSendable ? () async { final entry = cache.messages[index];
setState(() { if (index == cache.messages.length - 1) {
_isSendable = false; return Padding(
}); padding: const EdgeInsets.only(top: 12),
final message = Message( child: MessageBubble(
id: Message.generateId(), message: entry,
recipientId: widget.friend.id, ),
senderId: apiClient.userId,
type: MessageType.text,
content: _messageTextController.text,
sendTime: DateTime.now().toUtc(),
); );
try { }
mClient.sendMessage(message); return MessageBubble(
_messageTextController.clear(); message: entry,
setState(() {}); );
} catch (e) { },
ScaffoldMessenger.of(context).showSnackBar( ),
SnackBar( );
content: Text("Failed to send message\n$e", },
maxLines: null, ),
), ],
),
);
setState(() {
_isSendable = true;
});
}
} : null,
iconSize: 28,
icon: const Icon(Icons.send),
);
},
),
)
],
),
), ),
], ),
), MessageInputBar(
); recipient: widget.friend,
} disabled: cache == null || cache.error != null,
); onMessageSent: () {
setState(() {});
},
),
],
),
);
});
} }
} }

View file

@ -56,7 +56,7 @@ class _MyProfileDialogState extends State<MyProfileDialog> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(profile.username, style: tt.titleLarge), Text(profile.username, style: tt.titleLarge),
Text(profile.email, style: tt.labelMedium?.copyWith(color: Colors.white54),) Text(profile.email, style: tt.labelMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withAlpha(150)),)
], ],
), ),
GenericAvatar(imageUri: Aux.neosDbToHttp(profile.userProfile.iconUrl), radius: 24,) GenericAvatar(imageUri: Aux.neosDbToHttp(profile.userProfile.iconUrl), radius: 24,)

View file

@ -1,5 +1,7 @@
import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/client_holder.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -27,6 +29,31 @@ class SettingsPage extends StatelessWidget {
initialState: !sClient.currentSettings.notificationsDenied.valueOrDefault, initialState: !sClient.currentSettings.notificationsDenied.valueOrDefault,
onChanged: (value) async => await sClient.changeSettings(sClient.currentSettings.copyWith(notificationsDenied: !value)), onChanged: (value) async => await sClient.changeSettings(sClient.currentSettings.copyWith(notificationsDenied: !value)),
), ),
const ListSectionHeader(name: "Appearance"),
ListTile(
trailing: StatefulBuilder(
builder: (context, setState) {
return DropdownButton<ThemeMode>(
items: ThemeMode.values.map((mode) => DropdownMenuItem<ThemeMode>(
value: mode,
child: Text("${toBeginningOfSentenceCase(mode.name)}",),
)).toList(),
value: ThemeMode.values[sClient.currentSettings.themeMode.valueOrDefault],
onChanged: (ThemeMode? value) async {
final currentSetting = sClient.currentSettings.themeMode.value;
if (currentSetting != value?.index) {
await sClient.changeSettings(sClient.currentSettings.copyWith(themeMode: value?.index));
if (context.mounted) {
Phoenix.rebirth(context);
}
}
setState(() {});
},
);
}
),
title: const Text("Theme Mode"),
),
const ListSectionHeader(name: "Other"), const ListSectionHeader(name: "Other"),
ListTile( ListTile(
trailing: const Icon(Icons.logout), trailing: const Icon(Icons.logout),
@ -44,7 +71,7 @@ class SettingsPage extends StatelessWidget {
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")), TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await ClientHolder.of(context).apiClient.logout(context); await ClientHolder.of(context).apiClient.logout();
}, },
child: const Text("Yes"), child: const Text("Yes"),
), ),

View file

@ -8,6 +8,7 @@
#include <dynamic_color/dynamic_color_plugin.h> #include <dynamic_color/dynamic_color_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View file

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color dynamic_color
flutter_secure_storage_linux flutter_secure_storage_linux
record_linux
url_launcher_linux url_launcher_linux
) )

View file

@ -57,6 +57,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.2" version: "1.0.2"
camera:
dependency: "direct main"
description:
name: camera
sha256: "309b823e61f15ff6b5b2e4c0ff2e1512ea661cad5355f71fc581e510ae5b26bb"
url: "https://pub.dev"
source: hosted
version: "0.10.5"
camera_android:
dependency: transitive
description:
name: camera_android
sha256: "61bbae4af0204b9bbfd82182e313d405abf5a01bdb057ff6675f2269a5cab4fd"
url: "https://pub.dev"
source: hosted
version: "0.10.8+1"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "7ac8b950672716722af235eed7a7c37896853669800b7da706bb0a9fd41d3737"
url: "https://pub.dev"
source: hosted
version: "0.9.13+1"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "525017018d116c5db8c4c43ec2d9b1663216b369c9f75149158280168a7ce472"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
camera_web:
dependency: transitive
description:
name: camera_web
sha256: d77965f32479ee6d8f48205dcf10f845d7210595c6c00faa51eab265d1cae993
url: "https://pub.dev"
source: hosted
version: "0.3.1+3"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -89,8 +129,16 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
crypto: cross_file:
dependency: transitive dependency: transitive
description:
name: cross_file
sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9"
url: "https://pub.dev"
source: hosted
version: "0.3.3+4"
crypto:
dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
@ -153,6 +201,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.4" version: "6.1.4"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: c7a8e25ca60e7f331b153b0cb3d405828f18d3e72a6fa1d9440c86556fffc877
url: "https://pub.dev"
source: hosted
version: "5.3.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -214,6 +270,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@ -305,13 +369,53 @@ packages:
source: hosted source: hosted
version: "0.13.6" version: "0.13.6"
http_parser: http_parser:
dependency: transitive dependency: "direct main"
description: description:
name: http_parser name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.2" version: "4.0.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "9978d3510af4e6a902e545ce19229b926e6de6a1828d6134d3aab2e129a4d270"
url: "https://pub.dev"
source: hosted
version: "0.8.7+5"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: c2f3c66400649bd132f721c88218945d6406f693092b2f741b79ae9cdb046e59
url: "https://pub.dev"
source: hosted
version: "0.8.6+16"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "98f50d6b9f294c8ba35e25cc0d13b04bfddd25dbc8d32fa9d566a6572f2c081c"
url: "https://pub.dev"
source: hosted
version: "2.1.12"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: d779210bda268a03b57e923fb1e410f32f5c5e708ad256348bcbf1f44f558fd0
url: "https://pub.dev"
source: hosted
version: "0.8.7+4"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "1991219d9dbc42a99aff77e663af8ca51ced592cd6685c9485e3458302d3d4f8"
url: "https://pub.dev"
source: hosted
version: "2.6.3"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -441,7 +545,7 @@ packages:
source: hosted source: hosted
version: "1.8.3" version: "1.8.3"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2" sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2"
@ -496,6 +600,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" version: "1.11.1"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "33c6a1253d1f95fd06fa74b65b7ba907ae9811f9d5c1d3150e51417d04b8d6a8"
url: "https://pub.dev"
source: hosted
version: "10.2.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d8cc6a62ded6d0f49c6eac337e080b066ee3bce4d405bd9439a61e1f1927bfe8
url: "https://pub.dev"
source: hosted
version: "10.2.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: ee96ac32f5a8e6f80756e25b25b9f8e535816c8e6665a96b6d70681f8c4f7e85
url: "https://pub.dev"
source: hosted
version: "9.0.8"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: "68abbc472002b5e6dfce47fe9898c6b7d8328d58b5d2524f75e277c07a97eb84"
url: "https://pub.dev"
source: hosted
version: "3.9.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: f67cab14b4328574938ecea2db3475dad7af7ead6afab6338772c5f88963e38b
url: "https://pub.dev"
source: hosted
version: "0.1.2"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@ -544,6 +688,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.5" version: "6.0.5"
quiver:
dependency: transitive
description:
name: quiver
sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47
url: "https://pub.dev"
source: hosted
version: "3.2.1"
record:
dependency: "direct main"
description:
name: record
sha256: f703397f5a60d9b2b655b3acc94ba079b2d9a67dc0725bdb90ef2fee2441ebf7
url: "https://pub.dev"
source: hosted
version: "4.4.4"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: "348db92c4ec1b67b1b85d791381c8c99d7c6908de141e7c9edc20dad399b15ce"
url: "https://pub.dev"
source: hosted
version: "0.4.1"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: d1d0199d1395f05e218207e8cacd03eb9dc9e256ddfe2cfcbbb90e8edea06057
url: "https://pub.dev"
source: hosted
version: "0.2.2"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: "7a2d4ce7ac3752505157e416e4e0d666a54b1d5d8601701b7e7e5e30bec181b4"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: "219ffb4ca59b4338117857db56d3ffadbde3169bcaf1136f5f4d4656f4a2372d"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "42d545155a26b20d74f5107648dbb3382dbbc84dc3f1adc767040359e57a1345"
url: "https://pub.dev"
source: hosted
version: "0.7.1"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -613,6 +813,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:

View file

@ -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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.2.3+1 version: 1.3.0+1
environment: environment:
sdk: '>=3.0.0' sdk: '>=3.0.0'
@ -31,11 +31,9 @@ dependencies:
flutter: flutter:
sdk: 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 cupertino_icons: ^1.0.2
http: ^0.13.5 http: ^0.13.5
http_parser: ^4.0.2
uuid: ^3.0.7 uuid: ^3.0.7
flutter_secure_storage: ^8.0.0 flutter_secure_storage: ^8.0.0
intl: ^0.18.1 intl: ^0.18.1
@ -58,6 +56,13 @@ dependencies:
dynamic_color: ^1.6.5 dynamic_color: ^1.6.5
hive: ^2.2.3 hive: ^2.2.3
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
file_picker: ^5.3.0
record: ^4.4.4
camera: ^0.10.5
path_provider: ^2.0.15
crypto: ^3.0.3
image_picker: ^0.8.7+5
permission_handler: ^10.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -8,6 +8,8 @@
#include <dynamic_color/dynamic_color_plugin_c_api.h> #include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
@ -15,6 +17,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View file

@ -5,6 +5,8 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color dynamic_color
flutter_secure_storage_windows flutter_secure_storage_windows
permission_handler_windows
record_windows
url_launcher_windows url_launcher_windows
) )