commit
533e4d4297
41 changed files with 5372 additions and 602 deletions
|
@ -3,6 +3,9 @@
|
|||
|
||||
<!-- Required to fetch data from the 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
|
||||
android:label="Contacts++"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
@ -6,7 +6,7 @@ import 'package:contacts_plus_plus/models/friend.dart';
|
|||
class FriendApi {
|
||||
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()}" : ""}");
|
||||
ApiClient.checkResponse(response);
|
||||
client.checkResponse(response);
|
||||
final data = jsonDecode(response.body) as List;
|
||||
return data.map((e) => Friend.fromMap(e)).toList();
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ class MessageApi {
|
|||
"${userId.isEmpty ? "" : "&user=$userId"}"
|
||||
"&unread=$unreadOnly"
|
||||
);
|
||||
ApiClient.checkResponse(response);
|
||||
client.checkResponse(response);
|
||||
final data = jsonDecode(response.body) as List;
|
||||
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 {
|
||||
static Future<Iterable<User>> searchUsers(ApiClient client, {required String needle}) async {
|
||||
final response = await client.get("/users?name=$needle");
|
||||
ApiClient.checkResponse(response);
|
||||
client.checkResponse(response);
|
||||
final data = jsonDecode(response.body) as List;
|
||||
return data.map((e) => User.fromMap(e));
|
||||
}
|
||||
|
||||
static Future<User> getUser(ApiClient client, {required String userId}) async {
|
||||
final response = await client.get("/users/$userId/");
|
||||
ApiClient.checkResponse(response);
|
||||
client.checkResponse(response);
|
||||
final data = jsonDecode(response.body);
|
||||
return User.fromMap(data);
|
||||
}
|
||||
|
||||
static Future<UserStatus> getUserStatus(ApiClient client, {required String userId}) async {
|
||||
final response = await client.get("/users/$userId/status");
|
||||
ApiClient.checkResponse(response);
|
||||
client.checkResponse(response);
|
||||
final data = jsonDecode(response.body);
|
||||
return UserStatus.fromMap(data);
|
||||
}
|
||||
|
||||
static Future<void> notifyOnlineInstance(ApiClient client) async {
|
||||
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 {
|
||||
final pkginfo = await PackageInfo.fromPlatform();
|
||||
status = status.copyWith(
|
||||
neosVersion: "${pkginfo.version} of ${pkginfo.appName}",
|
||||
isMobile: true,
|
||||
);
|
||||
final body = jsonEncode(status.toMap(shallow: true));
|
||||
final response = await client.put("/users/${client.userId}/status", body: body);
|
||||
ApiClient.checkResponse(response);
|
||||
client.checkResponse(response);
|
||||
}
|
||||
|
||||
static Future<PersonalProfile> getPersonalProfile(ApiClient client) async {
|
||||
final response = await client.get("/users/${client.userId}");
|
||||
ApiClient.checkResponse(response);
|
||||
client.checkResponse(response);
|
||||
final data = jsonDecode(response.body);
|
||||
return PersonalProfile.fromMap(data);
|
||||
}
|
||||
|
@ -63,11 +64,11 @@ class UserApi {
|
|||
);
|
||||
final body = jsonEncode(friend.toMap(shallow: true));
|
||||
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 {
|
||||
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:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:html/parser.dart' as htmlparser;
|
||||
|
||||
|
@ -85,4 +86,19 @@ extension Format on Duration {
|
|||
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,
|
||||
required AuthenticationData authenticationData,
|
||||
required this.settingsClient,
|
||||
required super.child
|
||||
}) : apiClient = ApiClient(authenticationData: authenticationData);
|
||||
required super.child,
|
||||
required Function() onLogout,
|
||||
}) : apiClient = ApiClient(authenticationData: authenticationData, onLogout: onLogout);
|
||||
|
||||
static ClientHolder? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<ClientHolder>();
|
||||
|
@ -30,5 +31,6 @@ class ClientHolder extends InheritedWidget {
|
|||
@override
|
||||
bool updateShouldNotify(covariant ClientHolder oldWidget) =>
|
||||
oldWidget.apiClient != apiClient
|
||||
|| oldWidget.settingsClient != settingsClient;
|
||||
|| oldWidget.settingsClient != settingsClient
|
||||
|| oldWidget.notificationClient != notificationClient;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
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:http/http.dart' as http;
|
||||
import 'package:contacts_plus_plus/models/authentication_data.dart';
|
||||
|
@ -18,10 +16,12 @@ class ApiClient {
|
|||
static const String tokenKey = "token";
|
||||
static const String passwordKey = "password";
|
||||
|
||||
ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData;
|
||||
ApiClient({required AuthenticationData authenticationData, required this.onLogout}) : _authenticationData = authenticationData;
|
||||
|
||||
final AuthenticationData _authenticationData;
|
||||
final Logger _logger = Logger("API");
|
||||
// Saving the context here feels kinda cringe ngl
|
||||
final Function() onLogout;
|
||||
|
||||
AuthenticationData get authenticationData => _authenticationData;
|
||||
String get userId => _authenticationData.userId;
|
||||
|
@ -31,7 +31,7 @@ class ApiClient {
|
|||
required String username,
|
||||
required String password,
|
||||
bool rememberMe=true,
|
||||
bool rememberPass=false,
|
||||
bool rememberPass=true,
|
||||
String? oneTimePad,
|
||||
}) async {
|
||||
final body = {
|
||||
|
@ -54,11 +54,13 @@ class ApiClient {
|
|||
if (response.statusCode == 400) {
|
||||
throw "Invalid Credentials";
|
||||
}
|
||||
checkResponse(response);
|
||||
checkResponseCode(response);
|
||||
|
||||
final authData = AuthenticationData.fromMap(jsonDecode(response.body));
|
||||
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: machineIdKey, value: authData.secretMachineId);
|
||||
await storage.write(key: tokenKey, value: authData.token);
|
||||
|
@ -68,7 +70,9 @@ class ApiClient {
|
|||
}
|
||||
|
||||
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? machineId = await storage.read(key: machineIdKey);
|
||||
String? token = await storage.read(key: tokenKey);
|
||||
|
@ -79,7 +83,7 @@ class ApiClient {
|
|||
}
|
||||
|
||||
if (token != null) {
|
||||
final response = await http.get(buildFullUri("/users/$userId"), headers: {
|
||||
final response = await http.patch(buildFullUri("/userSessions"), headers: {
|
||||
"Authorization": "neos $userId:$token"
|
||||
});
|
||||
if (response.statusCode == 200) {
|
||||
|
@ -99,15 +103,15 @@ class ApiClient {
|
|||
return AuthenticationData.unauthenticated();
|
||||
}
|
||||
|
||||
Future<void> logout(BuildContext context) async {
|
||||
const FlutterSecureStorage storage = FlutterSecureStorage();
|
||||
Future<void> logout() async {
|
||||
const FlutterSecureStorage storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
await storage.delete(key: userIdKey);
|
||||
await storage.delete(key: machineIdKey);
|
||||
await storage.delete(key: tokenKey);
|
||||
await storage.delete(key: passwordKey);
|
||||
if (context.mounted) {
|
||||
Phoenix.rebirth(context);
|
||||
}
|
||||
onLogout();
|
||||
}
|
||||
|
||||
Future<void> extendSession() async {
|
||||
|
@ -117,22 +121,30 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
static void checkResponse(http.Response response) {
|
||||
final error = "(${response.statusCode}${kDebugMode ? "|${response.body}" : ""})";
|
||||
if (response.statusCode == 429) {
|
||||
throw "Sorry, you are being rate limited. $error";
|
||||
}
|
||||
void checkResponse(http.Response response) {
|
||||
if (response.statusCode == 403) {
|
||||
tryCachedLogin();
|
||||
// TODO: Show the login screen again if cached login was unsuccessful.
|
||||
throw "You are not authorized to do that. $error";
|
||||
}
|
||||
if (response.statusCode == 500) {
|
||||
throw "Internal server error. $error";
|
||||
}
|
||||
if (response.statusCode >= 300) {
|
||||
throw "Unknown Error. $error";
|
||||
tryCachedLogin().then((value) {
|
||||
if (!value.isAuthenticated) {
|
||||
onLogout();
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
|
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);
|
||||
await refreshFriendsListWithErrorHandler();
|
||||
await _refreshUnreads();
|
||||
_unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads());
|
||||
});
|
||||
_startWebsocket();
|
||||
_notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async {
|
||||
|
@ -85,6 +86,7 @@ class MessagingClient extends ChangeNotifier {
|
|||
void dispose() {
|
||||
_autoRefresh?.cancel();
|
||||
_notifyOnlineTimer?.cancel();
|
||||
_unreadSafeguard?.cancel();
|
||||
_wsChannel?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
@ -142,7 +144,7 @@ class MessagingClient extends ChangeNotifier {
|
|||
};
|
||||
_sendData(data);
|
||||
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
|
||||
cache.messages.add(message);
|
||||
cache.addMessage(message);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
@ -217,12 +219,10 @@ class MessagingClient extends ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> _refreshUnreads() async {
|
||||
_unreadSafeguard?.cancel();
|
||||
try {
|
||||
final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true);
|
||||
updateAllUnreads(unreadMessages.toList());
|
||||
} catch (_) {}
|
||||
_unreadSafeguard = Timer(_unreadSafeguardDuration, _refreshUnreads);
|
||||
}
|
||||
|
||||
void _sortFriendsCache() {
|
||||
|
@ -286,7 +286,7 @@ class MessagingClient extends ChangeNotifier {
|
|||
Uri.parse("${Config.neosHubUrl}/negotiate"),
|
||||
headers: _apiClient.authorizationHeader,
|
||||
);
|
||||
ApiClient.checkResponse(response);
|
||||
_apiClient.checkResponse(response);
|
||||
} catch (e) {
|
||||
throw "Failed to acquire connection info from Neos API: $e";
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@ class SettingsClient {
|
|||
final data = await _storage.read(key: _settingsKey);
|
||||
if (data == null) return;
|
||||
_currentSettings = Settings.fromMap(jsonDecode(data));
|
||||
|
||||
}
|
||||
|
||||
Future<void> changeSettings(Settings newSettings) async {
|
||||
|
|
105
lib/main.dart
105
lib/main.dart
|
@ -1,8 +1,8 @@
|
|||
import 'dart:developer';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:contacts_plus_plus/apis/github_api.dart';
|
||||
import 'package:contacts_plus_plus/client_holder.dart';
|
||||
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||
import 'package:contacts_plus_plus/clients/messaging_client.dart';
|
||||
import 'package:contacts_plus_plus/clients/settings_client.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));
|
||||
final settingsClient = SettingsClient();
|
||||
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 {
|
||||
const ContactsPlusPlus({required this.settingsClient, super.key});
|
||||
const ContactsPlusPlus({required this.settingsClient, required this.cachedAuthentication, super.key});
|
||||
|
||||
final SettingsClient settingsClient;
|
||||
final AuthenticationData cachedAuthentication;
|
||||
|
||||
@override
|
||||
State<ContactsPlusPlus> createState() => _ContactsPlusPlusState();
|
||||
|
@ -41,7 +48,7 @@ class ContactsPlusPlus extends StatefulWidget {
|
|||
|
||||
class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
||||
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
||||
AuthenticationData _authData = AuthenticationData.unauthenticated();
|
||||
late AuthenticationData _authData = widget.cachedAuthentication;
|
||||
bool _checkedForUpdate = false;
|
||||
|
||||
void showUpdateDialogOnFirstBuild(BuildContext context) {
|
||||
|
@ -95,43 +102,61 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClientHolder(
|
||||
settingsClient: widget.settingsClient,
|
||||
authenticationData: _authData,
|
||||
child: DynamicColorBuilder(
|
||||
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Contacts++',
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
textTheme: _typography.white,
|
||||
colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark),
|
||||
),
|
||||
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder
|
||||
builder: (context) {
|
||||
showUpdateDialogOnFirstBuild(context);
|
||||
final clientHolder = ClientHolder.of(context);
|
||||
return _authData.isAuthenticated ?
|
||||
ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
|
||||
create: (context) =>
|
||||
MessagingClient(
|
||||
apiClient: clientHolder.apiClient,
|
||||
notificationClient: clientHolder.notificationClient,
|
||||
),
|
||||
child: const FriendsList(),
|
||||
) :
|
||||
LoginScreen(
|
||||
onLoginSuccessful: (AuthenticationData authData) async {
|
||||
if (authData.isAuthenticated) {
|
||||
setState(() {
|
||||
_authData = authData;
|
||||
});
|
||||
return Phoenix(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return ClientHolder(
|
||||
settingsClient: widget.settingsClient,
|
||||
authenticationData: _authData,
|
||||
onLogout: () {
|
||||
setState(() {
|
||||
_authData = AuthenticationData.unauthenticated();
|
||||
});
|
||||
Phoenix.rebirth(context);
|
||||
},
|
||||
child: DynamicColorBuilder(
|
||||
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Contacts++',
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
textTheme: _typography.black,
|
||||
colorScheme: lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.light),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
textTheme: _typography.white,
|
||||
colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark),
|
||||
),
|
||||
themeMode: ThemeMode.values[widget.settingsClient.currentSettings.themeMode.valueOrDefault],
|
||||
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder
|
||||
builder: (context) {
|
||||
showUpdateDialogOnFirstBuild(context);
|
||||
final clientHolder = ClientHolder.of(context);
|
||||
return _authData.isAuthenticated ?
|
||||
ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
|
||||
create: (context) =>
|
||||
MessagingClient(
|
||||
apiClient: clientHolder.apiClient,
|
||||
notificationClient: clientHolder.notificationClient,
|
||||
),
|
||||
child: const FriendsList(),
|
||||
) :
|
||||
LoginScreen(
|
||||
onLoginSuccessful: (AuthenticationData authData) async {
|
||||
if (authData.isAuthenticated) {
|
||||
setState(() {
|
||||
_authData = authData;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -93,14 +93,14 @@ enum OnlineStatus {
|
|||
online;
|
||||
|
||||
static final List<Color> _colors = [
|
||||
Colors.white54,
|
||||
Colors.white54,
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
Colors.yellow,
|
||||
Colors.red,
|
||||
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) {
|
||||
return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
|
||||
|
|
|
@ -49,7 +49,7 @@ class Message implements Comparable {
|
|||
final MessageState state;
|
||||
|
||||
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();
|
||||
|
||||
factory Message.fromMap(Map map, {MessageState? withState}) {
|
||||
|
@ -65,7 +65,7 @@ class Message implements Comparable {
|
|||
type: type,
|
||||
content: map["content"],
|
||||
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) {
|
||||
final existingIdx = _messages.indexWhere((element) => element.id == message.id);
|
||||
if (existingIdx == -1) {
|
||||
_messages.add(message);
|
||||
_messages.insert(0, message);
|
||||
_ensureIntegrity();
|
||||
} else {
|
||||
_messages[existingIdx] = message;
|
||||
|
@ -175,7 +175,7 @@ class AudioClipContent {
|
|||
final String id;
|
||||
final String assetUri;
|
||||
|
||||
AudioClipContent({required this.id, required this.assetUri});
|
||||
const AudioClipContent({required this.id, required this.assetUri});
|
||||
|
||||
factory AudioClipContent.fromMap(Map map) {
|
||||
return AudioClipContent(
|
||||
|
@ -190,7 +190,7 @@ class MarkReadBatch {
|
|||
final List<String> ids;
|
||||
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() {
|
||||
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/sem_ver.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class SettingsEntry<T> {
|
||||
final T? value;
|
||||
|
@ -36,22 +38,29 @@ class Settings {
|
|||
final SettingsEntry<bool> notificationsDenied;
|
||||
final SettingsEntry<int> lastOnlineStatus;
|
||||
final SettingsEntry<String> lastDismissedVersion;
|
||||
final SettingsEntry<String> machineId;
|
||||
final SettingsEntry<int> themeMode;
|
||||
|
||||
Settings({
|
||||
SettingsEntry<bool>? notificationsDenied,
|
||||
SettingsEntry<int>? lastOnlineStatus,
|
||||
SettingsEntry<String>? lastDismissedVersion
|
||||
SettingsEntry<int>? themeMode,
|
||||
SettingsEntry<String>? lastDismissedVersion,
|
||||
SettingsEntry<String>? machineId
|
||||
})
|
||||
: notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false),
|
||||
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) {
|
||||
return Settings(
|
||||
notificationsDenied: retrieveEntryOrNull<bool>(map["notificationsDenied"]),
|
||||
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 {
|
||||
"notificationsDenied": notificationsDenied.toMap(),
|
||||
"lastOnlineStatus": lastOnlineStatus.toMap(),
|
||||
"themeMode": themeMode.toMap(),
|
||||
"lastDismissedVersion": lastDismissedVersion.toMap(),
|
||||
"machineId": machineId.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -76,14 +87,17 @@ class Settings {
|
|||
|
||||
Settings copyWith({
|
||||
bool? notificationsDenied,
|
||||
int? unreadCheckIntervalMinutes,
|
||||
int? lastOnlineStatus,
|
||||
int? themeMode,
|
||||
String? lastDismissedVersion,
|
||||
String? machineId,
|
||||
}) {
|
||||
return Settings(
|
||||
notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied),
|
||||
lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus),
|
||||
themeMode: this.themeMode.passThrough(themeMode),
|
||||
lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion),
|
||||
machineId: this.machineId.passThrough(machineId),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -15,11 +15,11 @@ class FriendOnlineStatusIndicator extends StatelessWidget {
|
|||
child: Image.asset(
|
||||
"assets/images/logo-white.png",
|
||||
alignment: Alignment.center,
|
||||
color: userStatus.onlineStatus.color,
|
||||
color: userStatus.onlineStatus.color(context),
|
||||
),
|
||||
) : Icon(
|
||||
userStatus.onlineStatus == OnlineStatus.offline ? Icons.circle_outlined : Icons.circle,
|
||||
color: userStatus.onlineStatus.color,
|
||||
color: userStatus.onlineStatus.color(context),
|
||||
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/clients/messaging_client.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/friends/expanding_input_fab.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);
|
||||
if (_clientHolder != clientHolder) {
|
||||
_clientHolder = clientHolder;
|
||||
final apiClient = _clientHolder!.apiClient;
|
||||
_refreshUserStatus();
|
||||
}
|
||||
}
|
||||
|
@ -79,7 +77,7 @@ class _FriendsListState extends State<FriendsList> {
|
|||
children: [
|
||||
Padding(
|
||||
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"),
|
||||
],
|
||||
|
@ -114,7 +112,7 @@ class _FriendsListState extends State<FriendsList> {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.circle, size: 16, color: item.color,),
|
||||
Icon(Icons.circle, size: 16, color: item.color(context),),
|
||||
const SizedBox(width: 8,),
|
||||
Text(toBeginningOfSentenceCase(item.name)!),
|
||||
],
|
||||
|
@ -254,6 +252,7 @@ class _FriendsListState extends State<FriendsList> {
|
|||
friends.sort((a, b) => a.username.length.compareTo(b.username.length));
|
||||
}
|
||||
return ListView.builder(
|
||||
physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
|
||||
itemCount: friends.length,
|
||||
itemBuilder: (context, index) {
|
||||
final friend = friends[index];
|
||||
|
|
|
@ -13,14 +13,14 @@ class GenericAvatar extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return imageUri.isEmpty ? CircleAvatar(
|
||||
radius: radius,
|
||||
foregroundColor: foregroundColor,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(placeholderIcon, color: foregroundColor,),
|
||||
) : CachedNetworkImage(
|
||||
imageBuilder: (context, imageProvider) {
|
||||
return CircleAvatar(
|
||||
foregroundImage: imageProvider,
|
||||
foregroundColor: foregroundColor,
|
||||
foregroundColor: Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
radius: radius,
|
||||
);
|
||||
|
@ -28,20 +28,20 @@ class GenericAvatar extends StatelessWidget {
|
|||
imageUrl: imageUri,
|
||||
placeholder: (context, url) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: Colors.white54,
|
||||
foregroundColor: foregroundColor,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
radius: radius,
|
||||
child: Padding(
|
||||
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(
|
||||
radius: radius,
|
||||
foregroundColor: foregroundColor,
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Icon(placeholderIcon, color: foregroundColor,),
|
||||
foregroundColor: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
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 _totpController = TextEditingController();
|
||||
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 _totpFocusNode;
|
||||
|
||||
|
@ -150,102 +145,94 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
appBar: AppBar(
|
||||
title: const Text("Contacts++"),
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: _cachedLoginFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData || snapshot.hasError) {
|
||||
final authData = snapshot.data;
|
||||
if (authData?.isAuthenticated ?? false) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ListView(
|
||||
controller: _scrollController,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 64),
|
||||
child: Center(
|
||||
child: Text("Sign In", style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.headlineMedium),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
return ListView(
|
||||
controller: _scrollController,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 64),
|
||||
child: Center(
|
||||
child: Text("Sign In", style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.headlineMedium),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
||||
child: TextField(
|
||||
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: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
controller: _usernameController,
|
||||
onEditingComplete: () => _passwordFocusNode.requestFocus(),
|
||||
controller: _totpController,
|
||||
focusNode: _totpFocusNode,
|
||||
onEditingComplete: submit,
|
||||
obscureText: false,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
labelText: 'Username',
|
||||
labelText: '2FA Code',
|
||||
),
|
||||
),
|
||||
),
|
||||
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',
|
||||
),
|
||||
),
|
||||
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"),
|
||||
),
|
||||
if (_needsTotp)
|
||||
Padding(
|
||||
),
|
||||
Center(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _errorOpacity,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
||||
child: TextField(
|
||||
controller: _totpController,
|
||||
focusNode: _totpFocusNode,
|
||||
onEditingComplete: submit,
|
||||
obscureText: false,
|
||||
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"),
|
||||
child: Text(_error, style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(color: Colors.red)),
|
||||
),
|
||||
),
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
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"]);
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Column(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: Aux.neosDbToHttp(content["thumbnailUri"]),
|
||||
imageBuilder: (context, image) {
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context, MaterialPageRoute(builder: (context) =>
|
||||
PhotoView(
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
imageProvider: photoAsset == null
|
||||
? image
|
||||
: CachedNetworkImageProvider(Aux.neosDbToHttp(photoAsset.imageUri)),
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: message.id),
|
||||
),
|
||||
),);
|
||||
},
|
||||
child: Hero(
|
||||
tag: message.id,
|
||||
child: ClipRRect(borderRadius: BorderRadius.circular(16), child: Image(image: image,)),
|
||||
),
|
||||
);
|
||||
},
|
||||
placeholder: (context, uri) => const CircularProgressIndicator(),
|
||||
SizedBox(
|
||||
height: 256,
|
||||
width: double.infinity,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: Aux.neosDbToHttp(content["thumbnailUri"]),
|
||||
imageBuilder: (context, image) {
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
PhotoAsset? photoAsset;
|
||||
try {
|
||||
photoAsset = PhotoAsset.fromTags((content["tags"] as List).map((e) => "$e").toList());
|
||||
} catch (_) {}
|
||||
await Navigator.push(
|
||||
context, MaterialPageRoute(builder: (context) =>
|
||||
PhotoView(
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
imageProvider: photoAsset == null
|
||||
? image
|
||||
: CachedNetworkImageProvider(Aux.neosDbToHttp(photoAsset.imageUri)),
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: message.id),
|
||||
),
|
||||
),);
|
||||
},
|
||||
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,),
|
||||
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 '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/widgets/messages/message_state_indicator.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class MessageAudioPlayer extends StatefulWidget {
|
||||
const MessageAudioPlayer({required this.message, this.foregroundColor, super.key});
|
||||
|
@ -18,44 +19,76 @@ class MessageAudioPlayer extends StatefulWidget {
|
|||
State<MessageAudioPlayer> createState() => _MessageAudioPlayerState();
|
||||
}
|
||||
|
||||
class _MessageAudioPlayerState extends State<MessageAudioPlayer> {
|
||||
class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBindingObserver {
|
||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||
Future? _audioFileFuture;
|
||||
double _sliderValue = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (Platform.isAndroid) {
|
||||
_audioPlayer.setUrl(
|
||||
Aux.neosDbToHttp(AudioClipContent
|
||||
.fromMap(jsonDecode(widget.message.content))
|
||||
.assetUri),
|
||||
preload: true).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off));
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
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) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme
|
||||
.of(context)
|
||||
.colorScheme
|
||||
.error,),
|
||||
const SizedBox(height: 4,),
|
||||
Text(error, textAlign: TextAlign.center,
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
Text(
|
||||
error,
|
||||
textAlign: TextAlign.center,
|
||||
softWrap: true,
|
||||
maxLines: 3,
|
||||
style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Theme
|
||||
.of(context)
|
||||
.colorScheme
|
||||
.error),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -67,116 +100,137 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> {
|
|||
if (!Platform.isAndroid) {
|
||||
return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform.");
|
||||
}
|
||||
|
||||
return IntrinsicWidth(
|
||||
child: StreamBuilder<PlayerState>(
|
||||
stream: _audioPlayer.playerStateStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final playerState = snapshot.data as PlayerState;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
stream: _audioPlayer.playerStateStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
|
||||
return _createErrorWidget("Failed to load audio-message.");
|
||||
}
|
||||
final playerState = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
switch (playerState.processingState) {
|
||||
case ProcessingState.idle:
|
||||
case ProcessingState.loading:
|
||||
case ProcessingState.buffering:
|
||||
break;
|
||||
case ProcessingState.ready:
|
||||
if (playerState.playing) {
|
||||
_audioPlayer.pause();
|
||||
} else {
|
||||
_audioPlayer.play();
|
||||
}
|
||||
break;
|
||||
case ProcessingState.completed:
|
||||
_audioPlayer.seek(Duration.zero);
|
||||
_audioPlayer.play();
|
||||
break;
|
||||
}
|
||||
},
|
||||
color: widget.foregroundColor,
|
||||
icon: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: playerState.processingState == ProcessingState.loading
|
||||
? const Center(child: CircularProgressIndicator(),)
|
||||
: Icon(((_audioPlayer.duration ?? Duration.zero) - _audioPlayer.position).inMilliseconds <
|
||||
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(),
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
FutureBuilder(
|
||||
future: _audioFileFuture,
|
||||
builder: (context, fileSnapshot) {
|
||||
if (fileSnapshot.hasError) {
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.warning),
|
||||
onPressed: null,
|
||||
);
|
||||
}
|
||||
return IconButton(
|
||||
onPressed: fileSnapshot.hasData &&
|
||||
snapshot.hasData &&
|
||||
playerState != null &&
|
||||
playerState.processingState != ProcessingState.loading
|
||||
? () {
|
||||
switch (playerState.processingState) {
|
||||
case ProcessingState.idle:
|
||||
case ProcessingState.loading:
|
||||
case ProcessingState.buffering:
|
||||
break;
|
||||
case ProcessingState.ready:
|
||||
if (playerState.playing) {
|
||||
_audioPlayer.pause();
|
||||
} else {
|
||||
_audioPlayer.play();
|
||||
}
|
||||
break;
|
||||
case ProcessingState.completed:
|
||||
_audioPlayer.seek(Duration.zero);
|
||||
_audioPlayer.play();
|
||||
break;
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
],
|
||||
}
|
||||
: 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(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
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,),
|
||||
],
|
||||
StreamBuilder(
|
||||
stream: _audioPlayer.positionStream,
|
||||
builder: (context, snapshot) {
|
||||
_sliderValue = _audioPlayer.duration == null
|
||||
? 0
|
||||
: (_audioPlayer.position.inMilliseconds / (_audioPlayer.duration!.inMilliseconds))
|
||||
.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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
|
||||
return _createErrorWidget("Failed to load audio-message.");
|
||||
} else {
|
||||
return const Center(child: CircularProgressIndicator(),);
|
||||
}
|
||||
}
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: switch (message.type) {
|
||||
MessageType.sessionInvite =>
|
||||
MessageSessionInvite(message: message, foregroundColor: foregroundColor,),
|
||||
MessageType.sessionInvite => MessageSessionInvite(message: message, foregroundColor: foregroundColor,),
|
||||
MessageType.object => MessageAsset(message: message, foregroundColor: foregroundColor,),
|
||||
MessageType.sound => MessageAudioPlayer(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/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/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:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -20,20 +20,15 @@ class MessagesList extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _MessagesListState extends State<MessagesList> with SingleTickerProviderStateMixin {
|
||||
final TextEditingController _messageTextController = TextEditingController();
|
||||
final ScrollController _sessionListScrollController = ScrollController();
|
||||
final ScrollController _messageScrollController = ScrollController();
|
||||
|
||||
bool _isSendable = false;
|
||||
bool _showSessionListScrollChevron = false;
|
||||
bool _showBottomBarShadow = false;
|
||||
bool _sessionListOpen = false;
|
||||
|
||||
double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0;
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageTextController.dispose();
|
||||
_sessionListScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
@ -47,262 +42,204 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
|||
_showSessionListScrollChevron = true;
|
||||
});
|
||||
}
|
||||
if (_sessionListScrollController.position.atEdge && _sessionListScrollController.position.pixels > 0
|
||||
&& _showSessionListScrollChevron) {
|
||||
if (_sessionListScrollController.position.atEdge &&
|
||||
_sessionListScrollController.position.pixels > 0 &&
|
||||
_showSessionListScrollChevron) {
|
||||
setState(() {
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
final apiClient = ClientHolder
|
||||
.of(context)
|
||||
.apiClient;
|
||||
var sessions = widget.friend.userStatus.activeSessions;
|
||||
final appBarColor = Theme
|
||||
.of(context)
|
||||
.colorScheme
|
||||
.surfaceVariant;
|
||||
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(
|
||||
final sessions = widget.friend.userStatus.activeSessions;
|
||||
final appBarColor = Theme.of(context).colorScheme.surfaceVariant;
|
||||
return Consumer<MessagingClient>(builder: (context, mClient, _) {
|
||||
final cache = mClient.getUserMessageCache(widget.friend.id);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (sessions.isNotEmpty) Container(
|
||||
constraints: const BoxConstraints(maxHeight: 64),
|
||||
decoration: BoxDecoration(
|
||||
color: appBarColor,
|
||||
border: const Border(top: BorderSide(width: 1, color: Colors.black26),)
|
||||
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),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: _sessionListScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: sessions.length,
|
||||
itemBuilder: (context, index) => SessionTile(session: sessions[index]),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: _shevronOpacity,
|
||||
curve: Curves.easeOut,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
appBarColor.withOpacity(0),
|
||||
appBarColor,
|
||||
appBarColor,
|
||||
],
|
||||
),
|
||||
),
|
||||
height: double.infinity,
|
||||
child: const Icon(Icons.chevron_right),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
],
|
||||
),
|
||||
bottom: sessions.isNotEmpty && _sessionListOpen
|
||||
? null
|
||||
: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (sessions.isNotEmpty)
|
||||
AnimatedRotation(
|
||||
turns: _sessionListOpen ? -1 / 4 : 1 / 4,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_sessionListOpen = !_sessionListOpen;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (cache == null) {
|
||||
return const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
)
|
||||
],
|
||||
scrolledUnderElevation: 0.0,
|
||||
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: [
|
||||
LinearProgressIndicator()
|
||||
],
|
||||
);
|
||||
}
|
||||
if (cache.error != null) {
|
||||
return DefaultErrorWidget(
|
||||
message: cache.error.toString(),
|
||||
onRetry: () {
|
||||
setState(() {
|
||||
mClient.deleteUserMessageCache(widget.friend.id);
|
||||
});
|
||||
mClient.loadUserMessageCache(widget.friend.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (cache.messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
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,
|
||||
ListView.builder(
|
||||
controller: _sessionListScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: sessions.length,
|
||||
itemBuilder: (context, index) => SessionTile(session: sessions[index]),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: _shevronOpacity,
|
||||
curve: Curves.easeOut,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
appBarColor.withOpacity(0),
|
||||
appBarColor,
|
||||
appBarColor,
|
||||
],
|
||||
),
|
||||
),
|
||||
height: double.infinity,
|
||||
child: const Icon(Icons.chevron_right),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
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(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: _showBottomBarShadow ? 8 : 0,
|
||||
color: Theme.of(context).shadowColor,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: TextField(
|
||||
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;
|
||||
});
|
||||
}
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) {
|
||||
if (cache == null) {
|
||||
return const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [LinearProgressIndicator()],
|
||||
);
|
||||
}
|
||||
if (cache.error != null) {
|
||||
return DefaultErrorWidget(
|
||||
message: cache.error.toString(),
|
||||
onRetry: () {
|
||||
setState(() {
|
||||
mClient.deleteUserMessageCache(widget.friend.id);
|
||||
});
|
||||
mClient.loadUserMessageCache(widget.friend.id);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: "Message ${widget.friend
|
||||
.username}...",
|
||||
hintMaxLines: 1,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24)
|
||||
);
|
||||
}
|
||||
if (cache.messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 4.0),
|
||||
child: Consumer<MessagingClient>(
|
||||
builder: (context, mClient, _) {
|
||||
return IconButton(
|
||||
splashRadius: 24,
|
||||
onPressed: _isSendable ? () async {
|
||||
setState(() {
|
||||
_isSendable = false;
|
||||
});
|
||||
final message = Message(
|
||||
id: Message.generateId(),
|
||||
recipientId: widget.friend.id,
|
||||
senderId: apiClient.userId,
|
||||
type: MessageType.text,
|
||||
content: _messageTextController.text,
|
||||
sendTime: DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
return Provider(
|
||||
create: (BuildContext context) => AudioCacheClient(),
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
|
||||
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,
|
||||
),
|
||||
);
|
||||
try {
|
||||
mClient.sendMessage(message);
|
||||
_messageTextController.clear();
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
}
|
||||
return MessageBubble(
|
||||
message: entry,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
),
|
||||
MessageInputBar(
|
||||
recipient: widget.friend,
|
||||
disabled: cache == null || cache.error != null,
|
||||
onMessageSent: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ class _MyProfileDialogState extends State<MyProfileDialog> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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,)
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:contacts_plus_plus/client_holder.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:url_launcher/url_launcher.dart';
|
||||
|
||||
|
@ -27,6 +29,31 @@ class SettingsPage extends StatelessWidget {
|
|||
initialState: !sClient.currentSettings.notificationsDenied.valueOrDefault,
|
||||
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"),
|
||||
ListTile(
|
||||
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: () async {
|
||||
await ClientHolder.of(context).apiClient.logout(context);
|
||||
await ClientHolder.of(context).apiClient.logout();
|
||||
},
|
||||
child: const Text("Yes"),
|
||||
),
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
#include <dynamic_color/dynamic_color_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>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
|
@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
dynamic_color
|
||||
flutter_secure_storage_linux
|
||||
record_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
214
pubspec.lock
214
pubspec.lock
|
@ -57,6 +57,46 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -89,8 +129,16 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
crypto:
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+4"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
|
||||
|
@ -153,6 +201,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
@ -214,6 +270,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "96af49aa6b57c10a312106ad6f71deed5a754029c24789bbf620ba784f0bd0b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.14"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -305,13 +369,53 @@ packages:
|
|||
source: hosted
|
||||
version: "0.13.6"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -441,7 +545,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.8.3"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2"
|
||||
|
@ -496,6 +600,46 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -544,6 +688,62 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -613,6 +813,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
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
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.2.3+1
|
||||
version: 1.3.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0'
|
||||
|
@ -31,11 +31,9 @@ dependencies:
|
|||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
http: ^0.13.5
|
||||
http_parser: ^4.0.2
|
||||
uuid: ^3.0.7
|
||||
flutter_secure_storage: ^8.0.0
|
||||
intl: ^0.18.1
|
||||
|
@ -58,6 +56,13 @@ dependencies:
|
|||
dynamic_color: ^1.6.5
|
||||
hive: ^2.2.3
|
||||
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:
|
||||
flutter_test:
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
#include <dynamic_color/dynamic_color_plugin_c_api.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>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
|
@ -15,6 +17,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
dynamic_color
|
||||
flutter_secure_storage_windows
|
||||
permission_handler_windows
|
||||
record_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in a new issue