Merge pull request #22 from Nutcake/media-upload

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

View file

@ -3,6 +3,9 @@
<!-- Required to fetch data from the internet. -->
<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}"

View file

@ -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();
}

View file

@ -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
View file

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

View file

@ -10,43 +10,44 @@ import 'package:package_info_plus/package_info_plus.dart';
class UserApi {
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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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;

View file

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

View file

@ -72,6 +72,7 @@ class MessagingClient extends ChangeNotifier {
box.delete(_lastUpdateKey);
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";
}

View file

@ -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 {

View file

@ -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;
});
}
},
);
}
},
);
}
)
),
)
),
),
);
}
),
);
}

View file

@ -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(),

View file

@ -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 {

View file

@ -0,0 +1,34 @@
import 'package:contacts_plus_plus/models/records/neos_db_asset.dart';
class AssetDiff extends NeosDBAsset{
final Diff state;
final bool isUploaded;
const AssetDiff({required hash, required bytes, required this.state, required this.isUploaded}) : super(hash: hash, bytes: bytes);
factory AssetDiff.fromMap(Map map) {
return AssetDiff(
hash: map["hash"],
bytes: map["bytes"],
state: Diff.fromInt(map["state"]),
isUploaded: map["isUploaded"],
);
}
}
enum Diff {
added,
unchanged,
removed;
factory Diff.fromInt(int? idx) {
return Diff.values[idx ?? 1];
}
factory Diff.fromString(String? text) {
return Diff.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
orElse: () => Diff.unchanged,
);
}
}

View file

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

View file

@ -0,0 +1,46 @@
enum UploadState {
uploadingChunks,
finalizing,
uploaded,
failed,
unknown;
factory UploadState.fromString(String? text) {
return UploadState.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
orElse: () => UploadState.unknown,
);
}
}
class AssetUploadData {
final String signature;
final String variant;
final String ownerId;
final int totalBytes;
final int chunkSize;
final int totalChunks;
final UploadState uploadState;
const AssetUploadData({
required this.signature,
required this.variant,
required this.ownerId,
required this.totalBytes,
required this.chunkSize,
required this.totalChunks,
required this.uploadState,
});
factory AssetUploadData.fromMap(Map map) {
return AssetUploadData(
signature: map["signature"],
variant: map["variant"] ?? "",
ownerId: map["ownerId"] ?? "",
totalBytes: map["totalBytes"] ?? -1,
chunkSize: map["chunkSize"] ?? -1,
totalChunks: map["totalChunks"] ?? -1,
uploadState: UploadState.fromString(map["uploadStat"]),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
class NeosDBAsset {
final String hash;
final int bytes;
const NeosDBAsset({required this.hash, required this.bytes});
factory NeosDBAsset.fromMap(Map map) {
return NeosDBAsset(hash: map["hash"] ?? "", bytes: map["bytes"] ?? -1);
}
factory NeosDBAsset.fromData(Uint8List data) {
final digest = sha256.convert(data);
return NeosDBAsset(hash: digest.toString().replaceAll("-", "").toLowerCase(), bytes: data.length);
}
Map toMap() {
return {
"hash": hash,
"bytes": bytes,
};
}
}

View file

@ -0,0 +1,41 @@
import 'package:contacts_plus_plus/models/records/asset_diff.dart';
enum RecordPreprocessState
{
preprocessing,
success,
failed;
factory RecordPreprocessState.fromString(String? text) {
return RecordPreprocessState.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
orElse: () => RecordPreprocessState.failed,
);
}
}
class PreprocessStatus {
final String id;
final String ownerId;
final String recordId;
final RecordPreprocessState state;
final num progress;
final String failReason;
final List<AssetDiff> resultDiffs;
const PreprocessStatus({required this.id, required this.ownerId, required this.recordId, required this.state,
required this.progress, required this.failReason, required this.resultDiffs,
});
factory PreprocessStatus.fromMap(Map map) {
return PreprocessStatus(
id: map["id"],
ownerId: map["ownerId"],
recordId: map["recordId"],
state: RecordPreprocessState.fromString(map["state"]),
progress: map["progress"],
failReason: map["failReason"] ?? "",
resultDiffs: (map["resultDiffs"] as List? ?? []).map((e) => AssetDiff.fromMap(e)).toList(),
);
}
}

View file

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

View file

@ -2,6 +2,8 @@ import 'dart:convert';
import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/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),
);
}
}

View file

@ -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,
);
}

View file

@ -4,7 +4,6 @@ import 'package:contacts_plus_plus/apis/user_api.dart';
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/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];

View file

@ -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,),
),
);
}

View file

@ -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();
)
],
);
}
),
);

View file

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

View file

@ -20,38 +20,46 @@ class MessageAsset extends StatelessWidget {
@override
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(

View file

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

View file

@ -2,11 +2,12 @@ import 'dart:convert';
import 'dart:io' show Platform;
import '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,
),
],
),
],
);
},
),
);
}
}
}

View file

@ -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,)

View file

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

View file

@ -1,9 +1,9 @@
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/audio_cache_client.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/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(() {});
},
),
],
),
);
});
}
}

View file

@ -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,)

View file

@ -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"),
),

View file

@ -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);

View file

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

View file

@ -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:

View file

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 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:

View file

@ -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"));
}

View file

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