commit
533e4d4297
41 changed files with 5372 additions and 602 deletions
|
@ -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}"
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
225
lib/apis/record_api.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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;
|
||||||
|
|
||||||
|
@ -85,4 +86,19 @@ extension Format on Duration {
|
||||||
return "$hh:$mm:$ss";
|
return "$hh:$mm:$ss";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
24
lib/clients/audio_cache_client.dart
Normal file
24
lib/clients/audio_cache_client.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
105
lib/main.dart
105
lib/main.dart
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
)
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
)
|
);
|
||||||
),
|
}
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
34
lib/models/records/asset_diff.dart
Normal file
34
lib/models/records/asset_diff.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
25
lib/models/records/asset_digest.dart
Normal file
25
lib/models/records/asset_digest.dart
Normal 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)}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
46
lib/models/records/asset_upload_data.dart
Normal file
46
lib/models/records/asset_upload_data.dart
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
|
||||||
|
enum UploadState {
|
||||||
|
uploadingChunks,
|
||||||
|
finalizing,
|
||||||
|
uploaded,
|
||||||
|
failed,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
factory UploadState.fromString(String? text) {
|
||||||
|
return UploadState.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
|
||||||
|
orElse: () => UploadState.unknown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetUploadData {
|
||||||
|
final String signature;
|
||||||
|
final String variant;
|
||||||
|
final String ownerId;
|
||||||
|
final int totalBytes;
|
||||||
|
final int chunkSize;
|
||||||
|
final int totalChunks;
|
||||||
|
final UploadState uploadState;
|
||||||
|
|
||||||
|
const AssetUploadData({
|
||||||
|
required this.signature,
|
||||||
|
required this.variant,
|
||||||
|
required this.ownerId,
|
||||||
|
required this.totalBytes,
|
||||||
|
required this.chunkSize,
|
||||||
|
required this.totalChunks,
|
||||||
|
required this.uploadState,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AssetUploadData.fromMap(Map map) {
|
||||||
|
return AssetUploadData(
|
||||||
|
signature: map["signature"],
|
||||||
|
variant: map["variant"] ?? "",
|
||||||
|
ownerId: map["ownerId"] ?? "",
|
||||||
|
totalBytes: map["totalBytes"] ?? -1,
|
||||||
|
chunkSize: map["chunkSize"] ?? -1,
|
||||||
|
totalChunks: map["totalChunks"] ?? -1,
|
||||||
|
uploadState: UploadState.fromString(map["uploadStat"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
2800
lib/models/records/json_template.dart
Normal file
2800
lib/models/records/json_template.dart
Normal file
File diff suppressed because it is too large
Load diff
26
lib/models/records/neos_db_asset.dart
Normal file
26
lib/models/records/neos_db_asset.dart
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
class NeosDBAsset {
|
||||||
|
final String hash;
|
||||||
|
final int bytes;
|
||||||
|
|
||||||
|
const NeosDBAsset({required this.hash, required this.bytes});
|
||||||
|
|
||||||
|
factory NeosDBAsset.fromMap(Map map) {
|
||||||
|
return NeosDBAsset(hash: map["hash"] ?? "", bytes: map["bytes"] ?? -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory NeosDBAsset.fromData(Uint8List data) {
|
||||||
|
final digest = sha256.convert(data);
|
||||||
|
return NeosDBAsset(hash: digest.toString().replaceAll("-", "").toLowerCase(), bytes: data.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map toMap() {
|
||||||
|
return {
|
||||||
|
"hash": hash,
|
||||||
|
"bytes": bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
41
lib/models/records/preprocess_status.dart
Normal file
41
lib/models/records/preprocess_status.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
303
lib/models/records/record.dart
Normal file
303
lib/models/records/record.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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,),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
63
lib/widgets/messages/camera_image_view.dart
Normal file
63
lib/widgets/messages/camera_image_view.dart
Normal 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"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(
|
||||||
|
|
304
lib/widgets/messages/message_attachment_list.dart
Normal file
304
lib/widgets/messages/message_attachment_list.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,116 +100,137 @@ 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,)
|
||||||
|
|
570
lib/widgets/messages/message_input_bar.dart
Normal file
570
lib/widgets/messages/message_input_bar.dart
Normal 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
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,)
|
||||||
|
|
|
@ -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"),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
214
pubspec.lock
214
pubspec.lock
|
@ -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:
|
||||||
|
|
13
pubspec.yaml
13
pubspec.yaml
|
@ -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:
|
||||||
|
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue