Compare commits
37 Commits
84924b480b
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 241a8b4d53 | |||
| 25d193e930 | |||
| e6924cc02d | |||
| 60985c6b37 | |||
| a015399b57 | |||
| 4b6c7998f3 | |||
| 26312e313f | |||
| b63b5d7fd2 | |||
| ca2943a94d | |||
| 32a4cd9361 | |||
| 2320e4ed17 | |||
| dee479a918 | |||
| 6895ef1e32 | |||
| 5c51eefa3e | |||
| 0d7ae321a7 | |||
| b4063a64e0 | |||
| 65154f2f5c | |||
| 19a22bd0d1 | |||
| a7da7baf5a | |||
| a344a94112 | |||
| f44861fead | |||
| 1c4a30ebb4 | |||
| 70e2ca3d3e | |||
| 0d4aee1625 | |||
| ad6aa33b7c | |||
| 284b5fa4df | |||
| b9aac0c3d7 | |||
| 6ce90e08ef | |||
| 5ac80d8d60 | |||
| 56e1fa52d8 | |||
| 3ae1b7d168 | |||
| d8f654c81c | |||
| cbcbd4d6dc | |||
| be899b5611 | |||
| 361bbe8d85 | |||
| 1e017af277 | |||
| c4c22a36bb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -60,3 +60,6 @@ lib/i18n/*.dart
|
||||
|
||||
# Android artifacts
|
||||
.android
|
||||
|
||||
# Build scripts
|
||||
release/
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"file": "File",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "The message has been retracted",
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it"
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||
"you": "You"
|
||||
},
|
||||
"errors": {
|
||||
"omemo": {
|
||||
@@ -158,7 +159,11 @@
|
||||
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
|
||||
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
|
||||
"stickerSettings": "Sticker settings",
|
||||
"newDeviceMessage": "${title} added a new encryption device"
|
||||
"newDeviceMessage": "${title} added a new encryption device",
|
||||
"messageHint": "Send a message...",
|
||||
"sendImages": "Send images",
|
||||
"sendFiles": "Send files",
|
||||
"takePhotos": "Take photos"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Add new contact",
|
||||
@@ -239,13 +244,17 @@
|
||||
"signOutConfirmTitle": "Sign Out",
|
||||
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
||||
"miscellaneousSection": "Miscellaneous",
|
||||
"debuggingSection": "Debugging"
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "General"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"licensed": "Licensed under GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "View source code"
|
||||
"viewSourceCode": "View source code",
|
||||
"nMoreToGo": "${n} more to go...",
|
||||
"debugMenuShown": "You are now a developer!",
|
||||
"debugMenuAlreadyShown": "You are already a developer!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"file": "Datei",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "Die Nachricht wurde zurückgezogen",
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht"
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
|
||||
"you": "Du"
|
||||
},
|
||||
"errors": {
|
||||
"omemo": {
|
||||
@@ -158,7 +159,11 @@
|
||||
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
|
||||
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
|
||||
"stickerSettings": "Stickereinstellungen",
|
||||
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt"
|
||||
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt",
|
||||
"messageHint": "Nachricht senden...",
|
||||
"sendImages": "Bilder senden",
|
||||
"sendFiles": "Dateien senden",
|
||||
"takePhotos": "Bilder aufnehmen"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Neuen Kontakt hinzufügen",
|
||||
@@ -239,13 +244,17 @@
|
||||
"signOutConfirmTitle": "Abmelden",
|
||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||
"miscellaneousSection": "Unterschiedlich",
|
||||
"debuggingSection": "Debugging"
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "Generell"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"licensed": "Lizensiert unter GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "Quellcode anschauen"
|
||||
"viewSourceCode": "Quellcode anschauen",
|
||||
"nMoreToGo": "Noch ${n}...",
|
||||
"debugMenuShown": "Du bist jetzt ein Entwickler!",
|
||||
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aussehen",
|
||||
|
||||
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
* Expose the debug menu by tapping the Moxxy icon on the about page 10 times
|
||||
* Maybe fix a connection race condition
|
||||
* Allow sharing media with the app when it was closed
|
||||
* Make quotes prettier
|
||||
* Make the bottom part of the conversation page prettier
|
||||
* Fix roster fetching
|
||||
* Fix OMEMO key generation
|
||||
@@ -10,12 +10,14 @@ Currently supported features include:
|
||||
<li>Typing indicators and message markers</li>
|
||||
<li>Chat backgrounds</li>
|
||||
<li>Runs in the background without Push Notifications</li>
|
||||
<li>OMEMO (Currently not compatible with most apps)</li>
|
||||
<li>Stickers</li>
|
||||
</ul>
|
||||
|
||||
For the best experience, I recommend a server that:
|
||||
<ul>
|
||||
<li>Supports direct TLS/StartTLS on the same domain as in the Jid</li>
|
||||
<li>Supports SCRAM-SHA-1 or SCRAM-SHA-256</li>
|
||||
<li>Supports SCRAM-SHA-1, SCRAM-SHA-256 or SCRAM-SHA-512</li>
|
||||
<li>Supports HTTP File Upload</li>
|
||||
<li>Supports Stream Management</li>
|
||||
<li>Supports Client State Indication</li>
|
||||
|
||||
@@ -65,9 +65,9 @@ import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:moxxyv2/ui/service/progress.dart';
|
||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
void setupLogging() {
|
||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||
@@ -81,6 +81,7 @@ void setupLogging() {
|
||||
Future<void> setupUIServices() async {
|
||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||
GetIt.I.registerSingleton<UISharingService>(UISharingService());
|
||||
}
|
||||
|
||||
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
@@ -88,7 +89,8 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
||||
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
||||
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc()); GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
||||
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
||||
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
|
||||
@@ -103,9 +105,6 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<StickerPackBloc>(StickerPackBloc());
|
||||
}
|
||||
|
||||
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
|
||||
// Padding(padding: ..., child: Column(children: [ ... ]))
|
||||
// TODO(Unknown): Theme the switches
|
||||
void main() async {
|
||||
setupLogging();
|
||||
await setupUIServices();
|
||||
@@ -186,7 +185,6 @@ void main() async {
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
|
||||
const MyApp(this.navigationKey, { super.key });
|
||||
final GlobalKey<NavigatorState> navigationKey;
|
||||
|
||||
@@ -200,46 +198,18 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initState();
|
||||
}
|
||||
|
||||
/// Async "version" of initState()
|
||||
Future<void> _initState() async {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_setupSharingHandler();
|
||||
// Set up receiving share intents
|
||||
await GetIt.I.get<UISharingService>().initialize();
|
||||
|
||||
// Lift the UI block
|
||||
GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
|
||||
}
|
||||
|
||||
Future<void> _handleSharedMedia(SharedMedia media) async {
|
||||
final attachments = media.attachments ?? [];
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionRequestedEvent(
|
||||
attachments.map((a) => a!.path).toList(),
|
||||
media.content,
|
||||
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setupSharingHandler() async {
|
||||
final handler = ShareHandlerPlatform.instance;
|
||||
final media = await handler.getInitialSharedMedia();
|
||||
|
||||
// Shared while the app was closed
|
||||
if (media != null) {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await handler.resetInitialSharedMedia();
|
||||
}
|
||||
|
||||
// Shared while the app is stil running
|
||||
handler.sharedMediaStream.listen((SharedMedia media) async {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await handler.resetInitialSharedMedia();
|
||||
});
|
||||
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -161,12 +161,20 @@ class AvatarService {
|
||||
// Publish data and metadata
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
await am.publishUserAvatar(
|
||||
|
||||
_log.finest('Publishing avatar...');
|
||||
final dataResult = await am.publishUserAvatar(
|
||||
base64,
|
||||
hash,
|
||||
public,
|
||||
);
|
||||
await am.publishUserAvatarMetadata(
|
||||
if (dataResult.isType<AvatarError>()) {
|
||||
_log.finest('Avatar data publishing failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO(Unknown): Make sure that the image is not too large.
|
||||
final metadataResult = await am.publishUserAvatarMetadata(
|
||||
UserAvatarMetadata(
|
||||
hash,
|
||||
bytes.length,
|
||||
@@ -177,7 +185,12 @@ class AvatarService {
|
||||
),
|
||||
public,
|
||||
);
|
||||
if (metadataResult.isType<AvatarError>()) {
|
||||
_log.finest('Avatar metadata publishing failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
_log.finest('Avatar publishing done');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
@@ -427,4 +428,12 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
'true',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'showDebugMenu',
|
||||
typeBool,
|
||||
boolToString(false),
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attrib
|
||||
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes3.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_stickers_privacy.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_xmpp_state.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0001_debug_menu.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
@@ -82,7 +83,7 @@ class DatabaseService {
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
password: key,
|
||||
version: 25,
|
||||
version: 26,
|
||||
onCreate: createDatabase,
|
||||
onConfigure: (db) async {
|
||||
// In order to do schema changes during database upgrades, we disable foreign
|
||||
@@ -191,6 +192,10 @@ class DatabaseService {
|
||||
_log.finest('Running migration for database version 25');
|
||||
await upgradeFromV24ToV25(db);
|
||||
}
|
||||
if (oldVersion < 26) {
|
||||
_log.finest('Running migration for database version 26');
|
||||
await upgradeFromV25ToV26(db);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV25ToV26(Database db) async {
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'showDebugMenu',
|
||||
typeBool,
|
||||
boolToString(false),
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
137
lib/service/httpfiletransfer/client.dart
Normal file
137
lib/service/httpfiletransfer/client.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
|
||||
typedef ProgressCallback = void Function(int total, int current);
|
||||
|
||||
@immutable
|
||||
class HttpPeekResult {
|
||||
const HttpPeekResult(this.contentType, this.contentLength);
|
||||
final String? contentType;
|
||||
final int? contentLength;
|
||||
}
|
||||
|
||||
/// Download the file found at [uri] into the file [destination]. [onProgress] is
|
||||
/// called whenever new data has been downloaded.
|
||||
///
|
||||
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||
Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgress) async {
|
||||
// TODO(Unknown): How do we close fileSink? Do we have to?
|
||||
IOSink? fileSink;
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.getUrl(uri);
|
||||
final resp = await req.close();
|
||||
|
||||
if (!isRequestOkay(resp.statusCode)) {
|
||||
client.close(force: true);
|
||||
return resp.statusCode;
|
||||
}
|
||||
|
||||
// The size of the remote file
|
||||
final length = resp.contentLength;
|
||||
|
||||
fileSink = File(destination).openWrite(mode: FileMode.append);
|
||||
var bytes = 0;
|
||||
final downloadCompleter = Completer<void>();
|
||||
unawaited(
|
||||
resp.transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
bytes += data.length;
|
||||
onProgress(length, bytes);
|
||||
|
||||
sink.add(data);
|
||||
},
|
||||
handleDone: (sink) {
|
||||
downloadCompleter.complete();
|
||||
},
|
||||
),
|
||||
).pipe(fileSink),
|
||||
);
|
||||
|
||||
// Wait for the download to complete
|
||||
await downloadCompleter.future;
|
||||
client.close(force: true);
|
||||
//await fileSink.close();
|
||||
|
||||
return resp.statusCode;
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
//await fileSink?.close();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the file found at [filePath] to [destination]. [headers] are HTTP headers
|
||||
/// that are added to the PUT request. [onProgress] is called whenever new data has
|
||||
/// been downloaded.
|
||||
///
|
||||
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||
Future<int?> uploadFile(Uri destination, Map<String, String> headers, String filePath, ProgressCallback onProgress) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.putUrl(destination);
|
||||
final file = File(filePath);
|
||||
final length = await file.length();
|
||||
req.contentLength = length;
|
||||
|
||||
// Set all known headers
|
||||
headers.forEach((headerName, headerValue) {
|
||||
req.headers.set(headerName, headerValue);
|
||||
});
|
||||
|
||||
var bytes = 0;
|
||||
final stream = file.openRead().transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
bytes += data.length;
|
||||
onProgress(length, bytes);
|
||||
|
||||
sink.add(data);
|
||||
},
|
||||
handleDone: (sink) {
|
||||
sink.close();
|
||||
},
|
||||
),
|
||||
);
|
||||
await req.addStream(stream);
|
||||
final resp = await req.close();
|
||||
|
||||
return resp.statusCode;
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a HEAD request to [uri].
|
||||
///
|
||||
/// Returns the content type and content length if the server responded. If an error
|
||||
/// occurs, returns null.
|
||||
Future<HttpPeekResult?> peekUrl(Uri uri) async {
|
||||
final client = HttpClient();
|
||||
|
||||
try {
|
||||
final req = await client.headUrl(uri);
|
||||
final resp = await req.close();
|
||||
|
||||
if (!isRequestOkay(resp.statusCode)) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
client.close(force: true);
|
||||
final contentType = resp.headers['Content-Type'];
|
||||
return HttpPeekResult(
|
||||
contentType != null && contentType.isNotEmpty ?
|
||||
contentType.first :
|
||||
null,
|
||||
resp.contentLength,
|
||||
);
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
@@ -43,7 +43,6 @@ bool isRequestOkay(int? statusCode) {
|
||||
}
|
||||
|
||||
class FileMetadata {
|
||||
|
||||
const FileMetadata({ this.mime, this.size });
|
||||
final String? mime;
|
||||
final int? size;
|
||||
@@ -53,15 +52,10 @@ class FileMetadata {
|
||||
/// does not specify the Content-Length header, null is returned.
|
||||
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
||||
Future<FileMetadata> peekFile(String url) async {
|
||||
final response = await Dio().headUri<dynamic>(Uri.parse(url));
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) return const FileMetadata();
|
||||
|
||||
final contentLengthHeaders = response.headers['Content-Length'];
|
||||
final contentTypeHeaders = response.headers['Content-Type'];
|
||||
final result = await peekUrl(Uri.parse(url));
|
||||
|
||||
return FileMetadata(
|
||||
mime: contentTypeHeaders?.first,
|
||||
size: contentLengthHeaders != null && contentLengthHeaders.isNotEmpty ? int.parse(contentLengthHeaders.first) : null,
|
||||
mime: result?.contentType,
|
||||
size: result?.contentLength,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:dio/dio.dart' as dio;
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
@@ -15,6 +14,7 @@ import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client;
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
@@ -102,19 +102,17 @@ class HttpFileTransferService {
|
||||
|
||||
/// Queue the download job [job] to be performed.
|
||||
Future<void> downloadFile(FileDownloadJob job) async {
|
||||
var canDownload = false;
|
||||
await _uploadLock.synchronized(() async {
|
||||
if (_currentDownloadJob != null) {
|
||||
_log.finest('Queuing up download task.');
|
||||
_downloadQueue.add(job);
|
||||
} else {
|
||||
_log.finest('Executing download task.');
|
||||
_currentDownloadJob = job;
|
||||
canDownload = true;
|
||||
|
||||
unawaited(_performFileDownload(job));
|
||||
}
|
||||
});
|
||||
|
||||
if (canDownload) {
|
||||
unawaited(_performFileDownload(job));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyFile(FileUploadJob job) async {
|
||||
@@ -183,7 +181,6 @@ class HttpFileTransferService {
|
||||
}
|
||||
|
||||
final file = File(path);
|
||||
final data = file.openRead();
|
||||
final stat = file.statSync();
|
||||
|
||||
// Request the upload slot
|
||||
@@ -200,119 +197,110 @@ class HttpFileTransferService {
|
||||
return;
|
||||
}
|
||||
final slot = slotResult.get<HttpFileUploadSlot>();
|
||||
try {
|
||||
final response = await dio.Dio().putUri<dynamic>(
|
||||
Uri.parse(slot.putUrl),
|
||||
options: dio.Options(
|
||||
headers: slot.headers,
|
||||
contentType: 'application/octet-stream',
|
||||
),
|
||||
data: data,
|
||||
onSendProgress: (count, total) {
|
||||
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
||||
// is open.
|
||||
if (job.recipients.length == 1) {
|
||||
final progress = count.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.messageMap.values.first.id,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
if (response.statusCode != 201) {
|
||||
// TODO(PapaTutuWawa): Trigger event
|
||||
_log.severe('Upload failed');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
return;
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaSize: stat.size,
|
||||
errorType: noError,
|
||||
encryptionScheme: encryption != null ?
|
||||
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
|
||||
null,
|
||||
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||
isUploading: false,
|
||||
srcUrl: slot.getUrl,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
StatelessFileSharingSource source;
|
||||
final plaintextHashes = <String, String>{};
|
||||
if (encryption != null) {
|
||||
source = StatelessFileSharingEncryptedSource(
|
||||
SFSEncryptionType.aes256GcmNoPadding,
|
||||
encryption.key,
|
||||
encryption.iv,
|
||||
encryption.ciphertextHashes,
|
||||
StatelessFileSharingUrlSource(slot.getUrl),
|
||||
);
|
||||
|
||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||
} else {
|
||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||
try {
|
||||
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
|
||||
.hashFile(job.path, HashFunction.sha256);
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||
}
|
||||
}
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: slot.getUrl,
|
||||
requestDeliveryReceipt: true,
|
||||
id: msg.sid,
|
||||
originId: msg.originId,
|
||||
sfs: StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
final uploadStatusCode = await client.uploadFile(
|
||||
Uri.parse(slot.putUrl),
|
||||
slot.headers,
|
||||
path,
|
||||
(total, current) {
|
||||
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
||||
// is open.
|
||||
if (job.recipients.length == 1) {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.messageMap.values.first.id,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
_log.finest('Sent message with file upload for ${job.path} to $recipient');
|
||||
|
||||
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
|
||||
if (isMultiMedia) {
|
||||
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
|
||||
unawaited(_copyFile(job));
|
||||
}
|
||||
}
|
||||
}
|
||||
} on dio.DioError {
|
||||
_log.finest('Upload failed due to connection error');
|
||||
},
|
||||
);
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
if (!isRequestOkay(uploadStatusCode)) {
|
||||
_log.severe('Upload failed');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
return;
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaSize: stat.size,
|
||||
errorType: noError,
|
||||
encryptionScheme: encryption != null ?
|
||||
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
|
||||
null,
|
||||
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||
isUploading: false,
|
||||
srcUrl: slot.getUrl,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
StatelessFileSharingSource source;
|
||||
final plaintextHashes = <String, String>{};
|
||||
if (encryption != null) {
|
||||
source = StatelessFileSharingEncryptedSource(
|
||||
SFSEncryptionType.aes256GcmNoPadding,
|
||||
encryption.key,
|
||||
encryption.iv,
|
||||
encryption.ciphertextHashes,
|
||||
StatelessFileSharingUrlSource(slot.getUrl),
|
||||
);
|
||||
|
||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||
} else {
|
||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||
try {
|
||||
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
|
||||
.hashFile(job.path, HashFunction.sha256);
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||
}
|
||||
}
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: slot.getUrl,
|
||||
requestDeliveryReceipt: true,
|
||||
id: msg.sid,
|
||||
originId: msg.originId,
|
||||
sfs: StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
),
|
||||
);
|
||||
_log.finest('Sent message with file upload for ${job.path} to $recipient');
|
||||
|
||||
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
|
||||
if (isMultiMedia) {
|
||||
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
|
||||
unawaited(_copyFile(job));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _pickNextUploadTask();
|
||||
@@ -348,7 +336,6 @@ class HttpFileTransferService {
|
||||
/// Actually attempt to download the file described by the job [job].
|
||||
Future<void> _performFileDownload(FileDownloadJob job) async {
|
||||
final filename = job.location.filename;
|
||||
_log.finest('Downloading ${job.location.url} as $filename');
|
||||
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
||||
|
||||
var downloadPath = downloadedPath;
|
||||
@@ -358,202 +345,173 @@ class HttpFileTransferService {
|
||||
downloadPath = pathlib.join(tempDir.path, filename);
|
||||
}
|
||||
|
||||
// Prepare file and completer.
|
||||
final file = await File(downloadedPath).create();
|
||||
final fileSink = file.openWrite(mode: FileMode.writeOnlyAppend);
|
||||
final downloadCompleter = Completer<void>();
|
||||
|
||||
dio.Response<dio.ResponseBody>? response;
|
||||
_log.finest('Downloading ${job.location.url} as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)');
|
||||
|
||||
int? downloadStatusCode;
|
||||
try {
|
||||
response = await dio.Dio().get<dio.ResponseBody>(
|
||||
job.location.url,
|
||||
options: dio.Options(
|
||||
responseType: dio.ResponseType.stream,
|
||||
_log.finest('Beginning download...');
|
||||
downloadStatusCode = await client.downloadFile(
|
||||
Uri.parse(job.location.url),
|
||||
downloadPath,
|
||||
(total, current) {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
_log.finest('Download done...');
|
||||
} catch (err) {
|
||||
_log.finest('Failed to download: $err');
|
||||
}
|
||||
|
||||
if (!isRequestOkay(downloadStatusCode)) {
|
||||
_log.warning('HTTP GET of ${job.location.url} returned $downloadStatusCode');
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
return;
|
||||
}
|
||||
|
||||
var integrityCheckPassed = true;
|
||||
final conv = (await GetIt.I.get<ConversationService>()
|
||||
.getConversationByJid(job.conversationJid))!;
|
||||
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
|
||||
if (decryptionKeysAvailable) {
|
||||
// The file was downloaded and is now being decrypted
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
),
|
||||
);
|
||||
|
||||
final downloadStream = response.data?.stream;
|
||||
|
||||
if (downloadStream != null) {
|
||||
final totalFileSizeString = response.headers['Content-Length']?.first;
|
||||
final totalFileSize = int.parse(totalFileSizeString!);
|
||||
|
||||
// Since acting on downloadStream events like to fire progress events
|
||||
// causes memory spikes relative to the file size, I chose to listen to
|
||||
// the created file instead and wait for its completion.
|
||||
|
||||
file.watch().listen((FileSystemEvent event) async {
|
||||
if (event is FileSystemCreateEvent ||
|
||||
event is FileSystemModifyEvent) {
|
||||
final fileSize = await File(downloadedPath).length();
|
||||
final progress = fileSize / totalFileSize;
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
if (progress >= 1 && !downloadCompleter.isCompleted) {
|
||||
downloadCompleter.complete();
|
||||
}
|
||||
}
|
||||
});
|
||||
downloadStream.listen(fileSink.add);
|
||||
|
||||
await downloadCompleter.future;
|
||||
await fileSink.flush();
|
||||
await fileSink.close();
|
||||
}
|
||||
} on dio.DioError catch (err) {
|
||||
_log.finest('Failed to download: $err');
|
||||
if (response.runtimeType != dio.Response<dio.ResponseBody>) {
|
||||
response = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRequestOkay(response?.statusCode)) {
|
||||
_log.warning('HTTP GET of ${job.location.url} returned ${response?.statusCode}');
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
return;
|
||||
} else {
|
||||
var integrityCheckPassed = true;
|
||||
final conv = (await GetIt.I.get<ConversationService>()
|
||||
.getConversationByJid(job.conversationJid))!;
|
||||
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
|
||||
if (decryptionKeysAvailable) {
|
||||
// The file was downloaded and is now being decrypted
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
),
|
||||
try {
|
||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
||||
downloadPath,
|
||||
downloadedPath,
|
||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
||||
job.location.key!,
|
||||
job.location.iv!,
|
||||
job.location.plaintextHashes ?? {},
|
||||
job.location.ciphertextHashes ?? {},
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
||||
downloadPath,
|
||||
downloadedPath,
|
||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
||||
job.location.key!,
|
||||
job.location.iv!,
|
||||
job.location.plaintextHashes ?? {},
|
||||
job.location.ciphertextHashes ?? {},
|
||||
);
|
||||
|
||||
if (!result.decryptionOkay) {
|
||||
_log.warning('Failed to decrypt $downloadPath');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
|
||||
} catch (ex) {
|
||||
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
|
||||
if (!result.decryptionOkay) {
|
||||
_log.warning('Failed to decrypt $downloadPath');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true));
|
||||
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
|
||||
} catch (ex) {
|
||||
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the MIME type
|
||||
final notification = GetIt.I.get<NotificationsService>();
|
||||
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
||||
|
||||
int? mediaWidth;
|
||||
int? mediaHeight;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(downloadedPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath');
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();
|
||||
} else if (mime.startsWith('video/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
/*
|
||||
// Generate thumbnail
|
||||
final thumbnailPath = await getVideoThumbnailPath(
|
||||
downloadedPath,
|
||||
job.conversationJid,
|
||||
);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(thumbnailPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath ($thumbnailPath)');
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();*/
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: File(downloadedPath).lengthSync(),
|
||||
isFileUploadNotification: false,
|
||||
warningType: integrityCheckPassed ?
|
||||
null :
|
||||
warningFileIntegrityCheckFailed,
|
||||
errorType: conv.encrypted && !decryptionKeysAvailable ?
|
||||
messageChatEncryptedButFileNot :
|
||||
null,
|
||||
isDownloading: false,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
downloadedPath,
|
||||
msg.timestamp,
|
||||
conv.id,
|
||||
job.mId,
|
||||
mime: mime,
|
||||
);
|
||||
final newConv = conv.copyWith(
|
||||
lastMessage: conv.lastMessage?.id == job.mId ?
|
||||
msg :
|
||||
conv.lastMessage,
|
||||
sharedMedia: [
|
||||
sharedMedium,
|
||||
...conv.sharedMedia,
|
||||
],
|
||||
);
|
||||
GetIt.I.get<ConversationService>().setConversation(newConv);
|
||||
|
||||
// Show a notification
|
||||
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(newConv, msg, '');
|
||||
}
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true));
|
||||
}
|
||||
|
||||
// Check the MIME type
|
||||
final notification = GetIt.I.get<NotificationsService>();
|
||||
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
||||
|
||||
int? mediaWidth;
|
||||
int? mediaHeight;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(downloadedPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath');
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();
|
||||
} else if (mime.startsWith('video/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
/*
|
||||
// Generate thumbnail
|
||||
final thumbnailPath = await getVideoThumbnailPath(
|
||||
downloadedPath,
|
||||
job.conversationJid,
|
||||
);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(thumbnailPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath ($thumbnailPath)');
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();*/
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: File(downloadedPath).lengthSync(),
|
||||
isFileUploadNotification: false,
|
||||
warningType: integrityCheckPassed ?
|
||||
null :
|
||||
warningFileIntegrityCheckFailed,
|
||||
errorType: conv.encrypted && !decryptionKeysAvailable ?
|
||||
messageChatEncryptedButFileNot :
|
||||
null,
|
||||
isDownloading: false,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
downloadedPath,
|
||||
msg.timestamp,
|
||||
conv.id,
|
||||
job.mId,
|
||||
mime: mime,
|
||||
);
|
||||
final newConv = conv.copyWith(
|
||||
lastMessage: conv.lastMessage?.id == job.mId ?
|
||||
msg :
|
||||
conv.lastMessage,
|
||||
sharedMedia: [
|
||||
sharedMedium,
|
||||
...conv.sharedMedia,
|
||||
],
|
||||
);
|
||||
GetIt.I.get<ConversationService>().setConversation(newConv);
|
||||
|
||||
// Show a notification
|
||||
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(newConv, msg, '');
|
||||
}
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
|
||||
// Free the download resources for the next one
|
||||
await _pickNextDownloadTask();
|
||||
}
|
||||
|
||||
Future<void> _pickNextDownloadTask() async {
|
||||
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
|
||||
|
||||
await _downloadLock.synchronized(() async {
|
||||
if (_downloadQueue.isNotEmpty) {
|
||||
_currentDownloadJob = _downloadQueue.removeFirst();
|
||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||
|
||||
// Only download if we have a connection
|
||||
if (GetIt.I.get<ConnectivityService>().currentState != ConnectivityResult.none) {
|
||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||
}
|
||||
} else {
|
||||
_currentDownloadJob = null;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import 'package:synchronized/synchronized.dart';
|
||||
/// backoff. This means that we perform the random backoff only as long as we are
|
||||
/// connected. Otherwise, we idle until we have a connection again.
|
||||
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||
|
||||
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
|
||||
: _isTesting = isTesting,
|
||||
_timerLock = Lock(),
|
||||
@@ -46,7 +45,7 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||
// Cancel the timer if it was running
|
||||
await _stopTimer();
|
||||
await setIsReconnecting(false);
|
||||
triggerConnectionLost!();
|
||||
await triggerConnectionLost!();
|
||||
} else if (regained && shouldReconnect) {
|
||||
// We should reconnect
|
||||
_log.finest('Network regained. Attempting reconnection...');
|
||||
|
||||
@@ -1,21 +1,91 @@
|
||||
import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
class MoxxyRosterManager extends RosterManager {
|
||||
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
@override
|
||||
Future<void> commitLastRosterVersion(String version) async {
|
||||
await GetIt.I.get<XmppService>().modifyXmppState((state) => state.copyWith(
|
||||
lastRosterVersion: version,
|
||||
),);
|
||||
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
return RosterCacheLoadResult(
|
||||
(await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion,
|
||||
(await rs.getRoster()).map((item) => XmppRosterItem(
|
||||
jid: item.jid,
|
||||
name: item.title,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask.isEmpty ? null : item.ask,
|
||||
groups: item.groups,
|
||||
),).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadLastRosterVersion() async {
|
||||
final ver = (await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion;
|
||||
if (ver != null) {
|
||||
setRosterVersion(ver);
|
||||
Future<void> commitRoster(String? version, List<String> removed, List<XmppRosterItem> modified, List<XmppRosterItem> added) async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final xs = GetIt.I.get<XmppService>();
|
||||
await xs.modifyXmppState((state) => state.copyWith(
|
||||
lastRosterVersion: version,
|
||||
),);
|
||||
|
||||
// Remove stale items
|
||||
for (final jid in removed) {
|
||||
await rs.removeRosterItemByJid(jid);
|
||||
}
|
||||
|
||||
// Create new roster items
|
||||
final rosterAdded = List<RosterItem>.empty(growable: true);
|
||||
for (final item in added) {
|
||||
rosterAdded.add(
|
||||
await rs.addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@').first,
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
|
||||
// TODO(PapaTutuWawa): Fetch the avatar
|
||||
}
|
||||
|
||||
// Update modified items
|
||||
final rosterModified = List<RosterItem>.empty(growable: true);
|
||||
for (final item in modified) {
|
||||
final ritem = await rs.getRosterItemByJid(item.jid);
|
||||
if (ritem == null) {
|
||||
//_log.warning('Could not find roster item with JID $jid during update');
|
||||
continue;
|
||||
}
|
||||
|
||||
rosterModified.add(
|
||||
await rs.updateRosterItem(
|
||||
ritem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Tell the UI
|
||||
// TODO(Unknown): This may not be the cleanest place to put it
|
||||
sendEvent(
|
||||
RosterDiffEvent(
|
||||
added: rosterAdded,
|
||||
modified: rosterModified,
|
||||
removed: removed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class OmemoService {
|
||||
);
|
||||
|
||||
if (device == null) {
|
||||
await commitDevice(device!);
|
||||
await commitDevice(await omemoManager.getDevice());
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||
}
|
||||
@@ -370,7 +370,6 @@ class OmemoService {
|
||||
|
||||
Future<void> removeAllSessions(String jid) async {
|
||||
await ensureInitialized();
|
||||
// TODO(PapaTutuWawa): Reset trust decisions in the TrustManager
|
||||
await omemoManager.removeAllRatchets(jid);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,222 +1,28 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
/// Closure which returns true if the jid of a [RosterItem] is equal to [jid].
|
||||
bool Function(RosterItem) _jidEqualsWrapper(String jid) {
|
||||
return (i) => i.jid == jid;
|
||||
}
|
||||
|
||||
typedef AddRosterItemFunction = Future<RosterItem> Function(
|
||||
String avatarUrl,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
String subscription,
|
||||
String ask,
|
||||
bool pseudoRosterItem,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
{
|
||||
List<String> groups,
|
||||
}
|
||||
);
|
||||
typedef UpdateRosterItemFunction = Future<RosterItem> Function(
|
||||
int id, {
|
||||
String? avatarUrl,
|
||||
String? avatarHash,
|
||||
String? title,
|
||||
String? subscription,
|
||||
String? ask,
|
||||
Object pseudoRosterItem,
|
||||
List<String>? groups,
|
||||
}
|
||||
);
|
||||
typedef RemoveRosterItemFunction = Future<void> Function(String jid);
|
||||
typedef GetConversationFunction = Future<Conversation?> Function(String jid);
|
||||
typedef SendEventFunction = void Function(BackgroundEvent event, { String? id });
|
||||
|
||||
/// Compare the local roster with the roster we received either by request or by push.
|
||||
/// Returns a diff between the roster before and after the request or the push.
|
||||
/// NOTE: This abuses the [RosterDiffEvent] type a bit.
|
||||
Future<RosterDiffEvent> processRosterDiff(
|
||||
List<RosterItem> currentRoster,
|
||||
List<XmppRosterItem> remoteRoster,
|
||||
bool isRosterPush,
|
||||
AddRosterItemFunction addRosterItemFromData,
|
||||
UpdateRosterItemFunction updateRosterItem,
|
||||
RemoveRosterItemFunction removeRosterItemByJid,
|
||||
GetConversationFunction getConversationByJid,
|
||||
SendEventFunction _sendEvent,
|
||||
) async {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final removed = List<String>.empty(growable: true);
|
||||
final modified = List<RosterItem>.empty(growable: true);
|
||||
final added = List<RosterItem>.empty(growable: true);
|
||||
|
||||
for (final item in remoteRoster) {
|
||||
if (isRosterPush) {
|
||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
||||
if (litem != null) {
|
||||
if (item.subscription == 'remove') {
|
||||
// We have the item locally but it has been removed
|
||||
|
||||
if (litem.contactId != null) {
|
||||
// We have the contact associated with a contact
|
||||
final newItem = await updateRosterItem(
|
||||
litem.id,
|
||||
ask: 'none',
|
||||
subscription: 'none',
|
||||
pseudoRosterItem: true,
|
||||
);
|
||||
modified.add(newItem);
|
||||
} else {
|
||||
await removeRosterItemByJid(item.jid);
|
||||
removed.add(item.jid);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Item has been modified
|
||||
final newItem = await updateRosterItem(
|
||||
litem.id,
|
||||
subscription: item.subscription,
|
||||
title: item.name,
|
||||
ask: item.ask,
|
||||
pseudoRosterItem: false,
|
||||
groups: item.groups,
|
||||
);
|
||||
|
||||
modified.add(newItem);
|
||||
|
||||
// Check if we have a conversation that we need to modify
|
||||
final conv = await getConversationByJid(item.jid);
|
||||
if (conv != null) {
|
||||
_sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: conv.copyWith(subscription: item.subscription),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Item does not exist locally
|
||||
if (item.subscription == 'remove') {
|
||||
// Item has been removed but we don't have it locally
|
||||
removed.add(item.jid);
|
||||
} else {
|
||||
// Item has been added and we don't have it locally
|
||||
final contactId = await css.getContactIdForJid(item.jid);
|
||||
final newItem = await addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@')[0],
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(item.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
groups: item.groups,
|
||||
);
|
||||
|
||||
added.add(newItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
||||
if (litem != null) {
|
||||
// Item is modified
|
||||
if (litem.title != item.name || litem.subscription != item.subscription || !listEquals(litem.groups, item.groups)) {
|
||||
final modifiedItem = await updateRosterItem(
|
||||
litem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
pseudoRosterItem: false,
|
||||
groups: item.groups,
|
||||
);
|
||||
modified.add(modifiedItem);
|
||||
|
||||
// Check if we have a conversation that we need to modify
|
||||
final conv = await getConversationByJid(litem.jid);
|
||||
if (conv != null) {
|
||||
_sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: conv.copyWith(subscription: item.subscription),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Item is new
|
||||
final contactId = await css.getContactIdForJid(item.jid);
|
||||
added.add(await addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.jid.split('@')[0],
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(item.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
groups: item.groups,
|
||||
),);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRosterPush) {
|
||||
for (final item in currentRoster) {
|
||||
final ritem = firstWhereOrNull(remoteRoster, (XmppRosterItem i) => i.jid == item.jid);
|
||||
if (ritem == null) {
|
||||
await removeRosterItemByJid(item.jid);
|
||||
removed.add(item.jid);
|
||||
}
|
||||
// We don't handle the modification case here as that is covered by the huge
|
||||
// loop above
|
||||
}
|
||||
}
|
||||
|
||||
return RosterDiffEvent(
|
||||
added: added,
|
||||
modified: modified,
|
||||
removed: removed,
|
||||
);
|
||||
}
|
||||
|
||||
class RosterService {
|
||||
|
||||
RosterService()
|
||||
: _rosterCache = HashMap(),
|
||||
_rosterLoaded = false,
|
||||
_log = Logger('RosterService');
|
||||
final HashMap<String, RosterItem> _rosterCache;
|
||||
bool _rosterLoaded;
|
||||
RosterService() : _log = Logger('RosterService');
|
||||
Map<String, RosterItem>? _rosterCache;
|
||||
final Logger _log;
|
||||
|
||||
Future<bool> isInRoster(String jid) async {
|
||||
if (!_rosterLoaded) {
|
||||
|
||||
Future<void> _loadRosterIfNeeded() async {
|
||||
if (_rosterCache == null) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
return _rosterCache.containsKey(jid);
|
||||
Future<bool> isInRoster(String jid) async {
|
||||
await _loadRosterIfNeeded();
|
||||
return _rosterCache!.containsKey(jid);
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
||||
@@ -250,7 +56,7 @@ class RosterService {
|
||||
);
|
||||
|
||||
// Update the cache
|
||||
_rosterCache[item.jid] = item;
|
||||
_rosterCache![item.jid] = item;
|
||||
|
||||
return item;
|
||||
}
|
||||
@@ -285,26 +91,26 @@ class RosterService {
|
||||
);
|
||||
|
||||
// Update cache
|
||||
_rosterCache[newItem.jid] = newItem;
|
||||
_rosterCache![newItem.jid] = newItem;
|
||||
|
||||
return newItem;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s removeRosterItem.
|
||||
Future<void> removeRosterItem(int id) async {
|
||||
// NOTE: This call ensures that _rosterCache != null
|
||||
await GetIt.I.get<DatabaseService>().removeRosterItem(id);
|
||||
|
||||
assert(_rosterCache != null, '_rosterCache must be non-null');
|
||||
|
||||
/// Update cache
|
||||
_rosterCache.removeWhere((_, value) => value.id == id);
|
||||
_rosterCache!.removeWhere((_, value) => value.id == id);
|
||||
}
|
||||
|
||||
/// Removes a roster item from the database based on its JID.
|
||||
Future<void> removeRosterItemByJid(String jid) async {
|
||||
if (!_rosterLoaded) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
await _loadRosterIfNeeded();
|
||||
|
||||
for (final item in _rosterCache.values) {
|
||||
for (final item in _rosterCache!.values) {
|
||||
if (item.jid == jid) {
|
||||
await removeRosterItem(item.id);
|
||||
return;
|
||||
@@ -314,17 +120,14 @@ class RosterService {
|
||||
|
||||
/// Returns the entire roster
|
||||
Future<List<RosterItem>> getRoster() async {
|
||||
if (!_rosterLoaded) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
|
||||
return _rosterCache.values.toList();
|
||||
await _loadRosterIfNeeded();
|
||||
return _rosterCache!.values.toList();
|
||||
}
|
||||
|
||||
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
|
||||
Future<RosterItem?> getRosterItemByJid(String jid) async {
|
||||
if (await isInRoster(jid)) {
|
||||
return _rosterCache[jid];
|
||||
return _rosterCache![jid];
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -335,9 +138,9 @@ class RosterService {
|
||||
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
||||
final items = await GetIt.I.get<DatabaseService>().loadRosterItems();
|
||||
|
||||
_rosterLoaded = true;
|
||||
_rosterCache = <String, RosterItem>{};
|
||||
for (final item in items) {
|
||||
_rosterCache[item.jid] = item;
|
||||
_rosterCache![item.jid] = item;
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -392,59 +195,6 @@ class RosterService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> requestRoster() async {
|
||||
final roster = GetIt.I.get<XmppConnection>().getManagerById<RosterManager>(rosterManager)!;
|
||||
Result<RosterRequestResult?, RosterError> result;
|
||||
if (roster.rosterVersioningAvailable()) {
|
||||
_log.fine('Stream supports roster versioning');
|
||||
result = await roster.requestRosterPushes();
|
||||
_log.fine('Requesting roster pushes done');
|
||||
} else {
|
||||
_log.fine('Stream does not support roster versioning');
|
||||
result = await roster.requestRoster();
|
||||
}
|
||||
|
||||
if (result.isType<RosterError>()) {
|
||||
_log.warning('Failed to request roster');
|
||||
return;
|
||||
}
|
||||
|
||||
final value = result.get<RosterRequestResult?>();
|
||||
if (value != null) {
|
||||
final currentRoster = await getRoster();
|
||||
sendEvent(
|
||||
await processRosterDiff(
|
||||
currentRoster,
|
||||
value.items,
|
||||
false,
|
||||
addRosterItemFromData,
|
||||
updateRosterItem,
|
||||
removeRosterItemByJid,
|
||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
||||
sendEvent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a roster push.
|
||||
Future<void> handleRosterPushEvent(RosterPushEvent event) async {
|
||||
final item = event.item;
|
||||
final currentRoster = await getRoster();
|
||||
sendEvent(
|
||||
await processRosterDiff(
|
||||
currentRoster,
|
||||
[ item ],
|
||||
true,
|
||||
addRosterItemFromData,
|
||||
updateRosterItem,
|
||||
removeRosterItemByJid,
|
||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
||||
sendEvent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> acceptSubscriptionRequest(String jid) async {
|
||||
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(jid);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ Future<void> entrypoint() async {
|
||||
)..registerManagers([
|
||||
MoxxyStreamManagementManager(),
|
||||
MoxxyDiscoManager(),
|
||||
MoxxyRosterManager(),
|
||||
RosterManager(MoxxyRosterStateManager()),
|
||||
MoxxyOmemoManager(),
|
||||
PingManager(),
|
||||
MessageManager(),
|
||||
|
||||
@@ -2,13 +2,13 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:dio/dio.dart' as dio;
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
@@ -167,20 +167,15 @@ class StickersService {
|
||||
stickerPackPath,
|
||||
sticker.hashes.values.first,
|
||||
);
|
||||
dio.Response<dynamic>? response;
|
||||
try {
|
||||
response = await dio.Dio().downloadUri(
|
||||
Uri.parse(sticker.urlSources.first),
|
||||
stickerPath,
|
||||
);
|
||||
} on dio.DioError catch(err) {
|
||||
_log.severe('Error downloading ${sticker.urlSources.first}: $err');
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
final downloadStatusCode = await downloadFile(
|
||||
Uri.parse(sticker.urlSources.first),
|
||||
stickerPath,
|
||||
(_, __) {},
|
||||
);
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) {
|
||||
_log.severe('Request not okay: $response');
|
||||
if (!isRequestOkay(downloadStatusCode)) {
|
||||
_log.severe('Request not okay: $downloadStatusCode');
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
stickers[i] = sticker.copyWith(
|
||||
|
||||
@@ -55,7 +55,6 @@ class XmppService {
|
||||
EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived),
|
||||
EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived),
|
||||
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
|
||||
EventTypeMatcher<RosterPushEvent>(_onRosterPush),
|
||||
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
|
||||
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
|
||||
EventTypeMatcher<MessageEvent>(_onMessage),
|
||||
@@ -663,7 +662,7 @@ class XmppService {
|
||||
|
||||
_log.finest('Connection connected. Is resumed? ${event.resumed}');
|
||||
unawaited(_initializeOmemoService(settings.jid.toString()));
|
||||
|
||||
|
||||
if (!event.resumed) {
|
||||
// Reset the blocking service's cache
|
||||
GetIt.I.get<BlocklistService>().onNewConnection();
|
||||
@@ -681,8 +680,10 @@ class XmppService {
|
||||
|
||||
// In section 5 of XEP-0198 it says that a client should not request the roster
|
||||
// in case of a stream resumption.
|
||||
await GetIt.I.get<RosterService>().requestRoster();
|
||||
|
||||
await connection
|
||||
.getManagerById<RosterManager>(rosterManager)!
|
||||
.requestRoster();
|
||||
|
||||
// TODO(Unknown): Once groupchats come into the equation, this gets trickier
|
||||
final roster = await GetIt.I.get<RosterService>().getRoster();
|
||||
for (final item in roster) {
|
||||
@@ -1146,15 +1147,12 @@ class XmppService {
|
||||
// Pre-process the message in case it is a reply to another message
|
||||
String? replyId;
|
||||
var messageBody = event.body;
|
||||
// TODO(Unknown): Implement
|
||||
if (event.reply != null /* && check if event.reply.to is okay */) {
|
||||
if (event.reply != null) {
|
||||
replyId = event.reply!.id;
|
||||
|
||||
// Strip the compatibility fallback, if specified
|
||||
if (event.reply!.start != null && event.reply!.end != null) {
|
||||
messageBody = messageBody.replaceRange(event.reply!.start!, event.reply!.end, '');
|
||||
_log.finest('Removed message reply compatibility fallback from message');
|
||||
}
|
||||
messageBody = event.reply!.removeFallback(messageBody);
|
||||
_log.finest('Removed message reply compatibility fallback from message');
|
||||
}
|
||||
|
||||
// The Url of the file embedded in the message, if there is one.
|
||||
@@ -1236,6 +1234,7 @@ class XmppService {
|
||||
final fts = GetIt.I.get<HttpFileTransferService>();
|
||||
final metadata = await peekFile(embeddedFile!.url);
|
||||
|
||||
_log.finest('Advertised file MIME: ${metadata.mime}');
|
||||
if (metadata.mime != null) mimeGuess = metadata.mime;
|
||||
|
||||
// Auto-download only if the file is below the set limit, if the limit is not set to
|
||||
@@ -1387,12 +1386,13 @@ class XmppService {
|
||||
sendEvent(MessageUpdatedEvent(message: message));
|
||||
|
||||
if (shouldDownload) {
|
||||
_log.finest('Advertised file MIME: ${_getMimeGuess(event)}');
|
||||
await GetIt.I.get<HttpFileTransferService>().downloadFile(
|
||||
FileDownloadJob(
|
||||
embeddedFile,
|
||||
message.id,
|
||||
conversationJid,
|
||||
null,
|
||||
_getMimeGuess(event),
|
||||
shouldShowNotification: false,
|
||||
),
|
||||
);
|
||||
@@ -1402,11 +1402,6 @@ class XmppService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRosterPush(RosterPushEvent event, { dynamic extra }) async {
|
||||
_log.fine("Roster push version: ${event.ver ?? "(null)"}");
|
||||
await GetIt.I.get<RosterService>().handleRosterPushEvent(event);
|
||||
}
|
||||
|
||||
Future<void> _onAvatarUpdated(AvatarUpdatedEvent event, { dynamic extra }) async {
|
||||
await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class PreferencesState with _$PreferencesState {
|
||||
@Default(true) bool enableStickers,
|
||||
@Default(true) bool autoDownloadStickersFromContacts,
|
||||
@Default(true) bool isStickersNodePublic,
|
||||
@Default(false) bool showDebugMenu,
|
||||
}) = _PreferencesState;
|
||||
|
||||
// JSON serialization
|
||||
|
||||
@@ -51,7 +51,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
on<BackgroundChangedEvent>(_onBackgroundChanged);
|
||||
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
|
||||
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
||||
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled);
|
||||
on<PickerToggledEvent>(_onPickerToggled);
|
||||
on<OwnJidReceivedEvent>(_onOwnJidReceived);
|
||||
on<OmemoSetEvent>(_onOmemoSet);
|
||||
on<MessageRetractedEvent>(_onMessageRetracted);
|
||||
@@ -64,7 +64,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
on<RecordingCanceledEvent>(_onRecordingCanceled);
|
||||
on<ReactionAddedEvent>(_onReactionAdded);
|
||||
on<ReactionRemovedEvent>(_onReactionRemoved);
|
||||
on<StickerPickerToggledEvent>(_onStickerPickerToggled);
|
||||
on<StickerSentEvent>(_onStickerSent);
|
||||
on<SoftKeyboardVisibilityChanged>(_onSoftKeyboardVisibilityChanged);
|
||||
|
||||
@@ -239,8 +238,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
messageText: '',
|
||||
quotedMessage: null,
|
||||
sendButtonState: defaultSendButtonState,
|
||||
emojiPickerVisible: false,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
messageEditing: false,
|
||||
messageEditingOriginalBody: '',
|
||||
messageEditingId: null,
|
||||
@@ -375,12 +373,11 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onEmojiPickerToggled(EmojiPickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
final newState = !state.emojiPickerVisible;
|
||||
Future<void> _onPickerToggled(PickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
final newState = !state.pickerVisible;
|
||||
emit(
|
||||
state.copyWith(
|
||||
emojiPickerVisible: newState,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: newState,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -461,8 +458,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
state.copyWith(
|
||||
isDragging: true,
|
||||
isRecording: true,
|
||||
emojiPickerVisible: false,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -643,16 +639,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onStickerPickerToggled(StickerPickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
await SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||
emit(
|
||||
state.copyWith(
|
||||
stickerPickerVisible: !state.stickerPickerVisible,
|
||||
emojiPickerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onStickerSent(StickerSentEvent event, Emitter<ConversationState> emit) async {
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendStickerCommand(
|
||||
@@ -666,17 +652,16 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
// Close the picker
|
||||
emit(
|
||||
state.copyWith(
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSoftKeyboardVisibilityChanged(SoftKeyboardVisibilityChanged event, Emitter<ConversationState> emit) async {
|
||||
if (event.visible && (state.emojiPickerVisible || state.stickerPickerVisible)) {
|
||||
if (event.visible && (state.pickerVisible)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
emojiPickerVisible: false,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,14 +98,8 @@ class ImagePickerRequestedEvent extends ConversationEvent {}
|
||||
class FilePickerRequestedEvent extends ConversationEvent {}
|
||||
|
||||
/// Triggered when the emoji button is pressed
|
||||
class EmojiPickerToggledEvent extends ConversationEvent {
|
||||
EmojiPickerToggledEvent({this.handleKeyboard = true});
|
||||
final bool handleKeyboard;
|
||||
}
|
||||
|
||||
/// Triggered when the sticker button is pressed
|
||||
class StickerPickerToggledEvent extends ConversationEvent {
|
||||
StickerPickerToggledEvent({this.handleKeyboard = true});
|
||||
class PickerToggledEvent extends ConversationEvent {
|
||||
PickerToggledEvent({this.handleKeyboard = true});
|
||||
final bool handleKeyboard;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
part of 'conversation_bloc.dart';
|
||||
|
||||
enum SendButtonState {
|
||||
audio,
|
||||
multi,
|
||||
send,
|
||||
cancelCorrection,
|
||||
}
|
||||
const defaultSendButtonState = SendButtonState.audio;
|
||||
const defaultSendButtonState = SendButtonState.multi;
|
||||
|
||||
@freezed
|
||||
class ConversationState with _$ConversationState {
|
||||
@@ -18,8 +18,7 @@ class ConversationState with _$ConversationState {
|
||||
@Default(<Message>[]) List<Message> messages,
|
||||
@Default(null) Conversation? conversation,
|
||||
@Default('') String backgroundPath,
|
||||
@Default(false) bool emojiPickerVisible,
|
||||
@Default(false) bool stickerPickerVisible,
|
||||
@Default(false) bool pickerVisible,
|
||||
@Default(false) bool messageEditing,
|
||||
@Default('') String messageEditingOriginalBody,
|
||||
@Default(null) String? messageEditingSid,
|
||||
|
||||
@@ -130,7 +130,13 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
||||
}
|
||||
|
||||
Future<void> _onRequested(ShareSelectionRequestedEvent event, Emitter<ShareSelectionState> emit) async {
|
||||
emit(state.copyWith(paths: event.paths, text: event.text, type: event.type));
|
||||
emit(
|
||||
state.copyWith(
|
||||
paths: event.paths,
|
||||
text: event.text,
|
||||
type: event.type,
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
|
||||
@@ -4,9 +4,24 @@ const Radius radiusLarge = Radius.circular(10);
|
||||
const Radius radiusSmall = Radius.circular(4);
|
||||
|
||||
const double textfieldRadiusRegular = 15;
|
||||
const double textfieldRadiusConversation = 20;
|
||||
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(top: 4, bottom: 4, left: 8, right: 8);
|
||||
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.all(10);
|
||||
const double textfieldRadiusConversation = 25;
|
||||
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: 8,
|
||||
right: 8,
|
||||
);
|
||||
|
||||
/// The inner TextField padding for the TextField on the ConversationPage.
|
||||
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.only(
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
left: 8,
|
||||
right: 8,
|
||||
);
|
||||
|
||||
/// The font size for the TextField on the ConversationPage
|
||||
const double textFieldFontSizeConversation = 18;
|
||||
|
||||
const int primaryColorHexRGBO = 0xffcf4aff;
|
||||
const int primaryColorAltHexRGB = 0xff9c18cd;
|
||||
@@ -17,15 +32,64 @@ const Color primaryColorAlt = Color(primaryColorAltHexRGB);
|
||||
const Color primaryColorDisabled = Color(primaryColorDisabledHexRGB);
|
||||
const Color textColorDisabled = Color(textColorDisabledHexRGB);
|
||||
|
||||
/// The color of a quote bubble displayed inside the TextField
|
||||
const Color bubbleQuoteInTextFieldColorLight = Color(0xffc7c7c7);
|
||||
const Color bubbleQuoteInTextFieldColorDark = Color(0xff2f2f2f);
|
||||
|
||||
/// The color of text inside a quote bubble inside the TextField
|
||||
const Color bubbleQuoteInTextFieldTextColorLight = Color(0xff373737);
|
||||
const Color bubbleQuoteInTextFieldTextColorDark = Color(0xffdadada);
|
||||
|
||||
/// The text color of the hint text on the ConversationPage
|
||||
const Color textFieldHintTextColorLight = Color(0xff4a4a4a);
|
||||
const Color textFieldHintTextColorDark = Color(0xffd6d6d6);
|
||||
|
||||
/// The regular text color of the TextField on the ConversationPage
|
||||
const Color textFieldTextColorLight = Colors.black;
|
||||
const Color textFieldTextColorDark = Colors.white;
|
||||
|
||||
/// The color of a bubble that was sent
|
||||
const Color bubbleColorSent = Color(0xff7e0bce);
|
||||
const Color bubbleColorSentQuoted = bubbleColorSent;
|
||||
|
||||
/// The color of the quote widget for a sent quote
|
||||
const Color bubbleColorSentQuoted = Color(0xff6e0ab4);
|
||||
|
||||
/// The color of a bubble that was received
|
||||
const Color bubbleColorReceived = Color(0xff222222);
|
||||
const Color bubbleColorReceivedQuoted = bubbleColorReceived;
|
||||
|
||||
/// The color of the quote widget for a received quote
|
||||
const Color bubbleColorReceivedQuoted = Color(0xff2f2f2f);
|
||||
|
||||
/// The color of a bubble when the message is unencrypted while the chat is encrypted
|
||||
const Color bubbleColorUnencrypted = Color(0xffd40000);
|
||||
|
||||
/// The color of a bubble for a pseudo message of type new device
|
||||
const Color bubbleColorNewDevice = Color(0xffeee8d5);
|
||||
|
||||
/// The color of text within a regular bubble
|
||||
const Color bubbleTextColor = Color(0xffffffff);
|
||||
|
||||
/// The color of text within a quote widget
|
||||
const Color bubbleTextQuoteColor = Color(0xffdadada);
|
||||
|
||||
/// The color of the sender name in a quote
|
||||
const Color bubbleTextQuoteSenderColor = Color(0xffff90ff);
|
||||
|
||||
/// The color of the input text field of the conversation page
|
||||
const Color conversationTextFieldColorLight = Color(0xffe6e6e6);
|
||||
const Color conversationTextFieldColorDark = Color(0xff414141);
|
||||
|
||||
/// The width of the white left border of quote widgets
|
||||
const double quoteLeftBorderWidth = 4;
|
||||
|
||||
/// The background color of the avatar when no actual avatar is available
|
||||
const Color profileFallbackBackgroundColorLight = Color(0xffc3c3c3);
|
||||
const Color profileFallbackBackgroundColorDark = Color(0xff424242);
|
||||
|
||||
/// The text color of the avatar fallback text
|
||||
const Color profileFallbackTextColorLight = Color(0xff343434);
|
||||
const Color profileFallbackTextColorDark = Colors.white;
|
||||
|
||||
const Color settingsSectionTitleColor = Color(0xffb72fe7);
|
||||
|
||||
const double paddingVeryLarge = 64;
|
||||
@@ -40,16 +104,20 @@ const double fontsizeBody = 15;
|
||||
const double fontsizeBodyOnlyEmojis = 30;
|
||||
const double fontsizeSubbody = 10;
|
||||
|
||||
// The color for a shared media item
|
||||
/// The color for a shared media item
|
||||
final Color sharedMediaItemBackgroundColor = Colors.grey.shade500;
|
||||
// The color for a shared media summary
|
||||
|
||||
/// The color for a shared media summary
|
||||
final Color sharedMediaSummaryBackgroundColor = Colors.grey.shade500;
|
||||
|
||||
// The translucent black we use when we need to ensure good contrast, for example when
|
||||
// displaying the download progress indicator.
|
||||
/// The translucent black we use when we need to ensure good contrast, for example when
|
||||
/// displaying the download progress indicator.
|
||||
final backdropBlack = Colors.black.withAlpha(150);
|
||||
|
||||
// Navigation constants
|
||||
/// The height of the emoji/sticker picker
|
||||
const double pickerHeight = 300;
|
||||
|
||||
/// Navigation constants
|
||||
const String cropRoute = '/crop';
|
||||
const String introRoute = '/intro';
|
||||
const String loginRoute = '/route';
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
||||
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
@@ -14,8 +13,9 @@ import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/timer.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/media/media.dart';
|
||||
import 'package:moxxyv2/ui/widgets/sticker_picker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/combined_picker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/textfield.dart';
|
||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
|
||||
@@ -32,7 +32,55 @@ class _TextFieldIconButton extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextFieldRecordButton extends StatelessWidget {
|
||||
const _TextFieldRecordButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LongPressDraggable<int>(
|
||||
data: 1,
|
||||
axis: Axis.vertical,
|
||||
onDragStarted: () {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragStartedEvent(),
|
||||
);
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragEndedEvent(),
|
||||
);
|
||||
},
|
||||
childWhenDragging: const SizedBox(),
|
||||
feedback: SizedBox(
|
||||
width: 45,
|
||||
height: 45,
|
||||
child: FloatingActionButton(
|
||||
onPressed: null,
|
||||
heroTag: 'fabDragged',
|
||||
backgroundColor: Colors.red.shade600,
|
||||
child: BlinkingIcon(
|
||||
icon: Icons.mic,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
start: Colors.white,
|
||||
end: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
Icons.mic,
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
@@ -43,12 +91,16 @@ class _TextFieldIconButton extends StatelessWidget {
|
||||
class ConversationBottomRow extends StatefulWidget {
|
||||
const ConversationBottomRow(
|
||||
this.controller,
|
||||
this.focusNode, {
|
||||
this.tabController,
|
||||
this.focusNode,
|
||||
this.speedDialValueNotifier, {
|
||||
super.key,
|
||||
}
|
||||
);
|
||||
final TextEditingController controller;
|
||||
final TabController tabController;
|
||||
final FocusNode focusNode;
|
||||
final ValueNotifier<bool> speedDialValueNotifier;
|
||||
|
||||
@override
|
||||
ConversationBottomRowState createState() => ConversationBottomRowState();
|
||||
@@ -56,7 +108,7 @@ class ConversationBottomRow extends StatefulWidget {
|
||||
|
||||
class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
late StreamSubscription<bool> _keyboardVisibilitySubscription;
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -78,22 +130,21 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTextColor(BuildContext context) {
|
||||
// TODO(Unknown): Work on the colors
|
||||
if (MediaQuery.of(context).platformBrightness == Brightness.dark) {
|
||||
return Colors.white;
|
||||
}
|
||||
|
||||
return Colors.black;
|
||||
}
|
||||
|
||||
IconData _getSendButtonIcon(ConversationState state) {
|
||||
switch (state.sendButtonState) {
|
||||
case SendButtonState.audio: return Icons.mic;
|
||||
case SendButtonState.multi: return Icons.add;
|
||||
case SendButtonState.send: return Icons.send;
|
||||
case SendButtonState.cancelCorrection: return Icons.clear;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getPickerIcon() {
|
||||
if (widget.tabController.index == 0) {
|
||||
return Icons.insert_emoticon;
|
||||
}
|
||||
|
||||
return PhosphorIcons.stickerBold;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -108,17 +159,25 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState || prev.quotedMessage != next.quotedMessage || prev.emojiPickerVisible != next.emojiPickerVisible || prev.messageText != next.messageText || prev.messageEditing != next.messageEditing || prev.messageEditingOriginalBody != next.messageEditingOriginalBody,
|
||||
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState || prev.quotedMessage != next.quotedMessage || prev.pickerVisible != next.pickerVisible || prev.messageText != next.messageText || prev.messageEditing != next.messageEditing || prev.messageEditingOriginalBody != next.messageEditingOriginalBody || prev.isRecording != next.isRecording,
|
||||
builder: (context, state) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
// TODO(Unknown): Work on the colors
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
textColor: _getTextColor(context),
|
||||
enableBoxShadow: true,
|
||||
backgroundColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldColor,
|
||||
textColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldTextColor,
|
||||
maxLines: 5,
|
||||
hintText: 'Send a message...',
|
||||
hintText: t.pages.conversation.messageHint,
|
||||
hintTextColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldHintTextColor,
|
||||
isDense: true,
|
||||
onChanged: (value) {
|
||||
context.read<ConversationBloc>().add(
|
||||
@@ -126,51 +185,34 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
);
|
||||
},
|
||||
contentPadding: textfieldPaddingConversation,
|
||||
fontSize: textFieldFontSizeConversation,
|
||||
cornerRadius: textfieldRadiusConversation,
|
||||
controller: widget.controller,
|
||||
topWidget: state.quotedMessage != null ? buildQuoteMessageWidget(
|
||||
state.quotedMessage!,
|
||||
isSent(state.quotedMessage!, state.jid),
|
||||
resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()),
|
||||
) : null,
|
||||
topWidget: state.quotedMessage != null ?
|
||||
buildQuoteMessageWidget(
|
||||
state.quotedMessage!,
|
||||
isSent(state.quotedMessage!, state.jid),
|
||||
resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()),
|
||||
) :
|
||||
null,
|
||||
focusNode: widget.focusNode,
|
||||
shouldSummonKeyboard: () => !state.emojiPickerVisible,
|
||||
shouldSummonKeyboard: () => !state.pickerVisible,
|
||||
prefixIcon: IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
state.emojiPickerVisible ?
|
||||
Icons.keyboard :
|
||||
Icons.insert_emoticon,
|
||||
color: primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
context.read<ConversationBloc>().add(EmojiPickerToggledEvent());
|
||||
},
|
||||
),
|
||||
Visibility(
|
||||
visible: state.messageText.isEmpty && state.quotedMessage == null,
|
||||
child: InkWell(
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
PhosphorIcons.stickerBold,
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: _TextFieldIconButton(
|
||||
state.pickerVisible ?
|
||||
Icons.keyboard :
|
||||
_getPickerIcon(),
|
||||
() {
|
||||
context.read<ConversationBloc>().add(
|
||||
StickerPickerToggledEvent(),
|
||||
PickerToggledEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -181,31 +223,10 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
suffixIcon: state.messageText.isEmpty && state.quotedMessage == null ?
|
||||
IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
_TextFieldIconButton(
|
||||
Icons.attach_file,
|
||||
() {
|
||||
context.read<ConversationBloc>().add(
|
||||
FilePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
_TextFieldIconButton(
|
||||
Icons.photo_camera,
|
||||
() {
|
||||
showNotImplementedDialog(
|
||||
'taking photos',
|
||||
context,
|
||||
);
|
||||
},
|
||||
),
|
||||
_TextFieldIconButton(
|
||||
Icons.image,
|
||||
() {
|
||||
context.read<ConversationBloc>().add(
|
||||
ImagePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
children: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: _TextFieldRecordButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -217,23 +238,129 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: AnimatedOpacity(
|
||||
opacity: state.isRecording ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: IgnorePointer(
|
||||
ignoring: state.isRecording,
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: SpeedDial(
|
||||
icon: _getSendButtonIcon(state),
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
children: [
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.image),
|
||||
onTap: () {
|
||||
context.read<ConversationBloc>().add(
|
||||
ImagePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.sendImages,
|
||||
),
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.file_present),
|
||||
onTap: () {
|
||||
context.read<ConversationBloc>().add(
|
||||
FilePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.sendFiles,
|
||||
),
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.photo_camera),
|
||||
onTap: () {
|
||||
showNotImplementedDialog('taking photos', context);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.takePhotos,
|
||||
),
|
||||
],
|
||||
openCloseDial: widget.speedDialValueNotifier,
|
||||
onPress: () {
|
||||
switch (state.sendButtonState) {
|
||||
case SendButtonState.cancelCorrection:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageEditCancelledEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
case SendButtonState.send:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageSentEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
case SendButtonState.multi:
|
||||
widget.speedDialValueNotifier.value = !widget.speedDialValueNotifier.value;
|
||||
return;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.stickerPickerVisible != next.stickerPickerVisible,
|
||||
buildWhen: (prev, next) => prev.pickerVisible != next.pickerVisible,
|
||||
builder: (context, state) => Offstage(
|
||||
offstage: !state.stickerPickerVisible,
|
||||
child: StickerPicker(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
offstage: !state.pickerVisible,
|
||||
child: CombinedPicker(
|
||||
tabController: widget.tabController,
|
||||
onEmojiTapped: (emoji) {
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final selection = widget.controller.selection;
|
||||
final baseOffset = max(selection.baseOffset, 0);
|
||||
final extentOffset = max(selection.extentOffset, 0);
|
||||
final prefix = bloc.state.messageText.substring(0, baseOffset);
|
||||
final suffix = bloc.state.messageText.substring(extentOffset);
|
||||
final newText = '$prefix${emoji.emoji}$suffix';
|
||||
final newValue = baseOffset + emoji.emoji.codeUnits.length;
|
||||
bloc.add(MessageTextChangedEvent(newText));
|
||||
widget.controller
|
||||
..text = newText
|
||||
..selection = TextSelection(
|
||||
baseOffset: newValue,
|
||||
extentOffset: newValue,
|
||||
);
|
||||
},
|
||||
onBackspaceTapped: () {
|
||||
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final text = bloc.state.messageText;
|
||||
final selection = widget.controller.selection;
|
||||
final cursorPosition = widget.controller.selection.base.offset;
|
||||
|
||||
if (cursorPosition < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTextBeforeCursor = selection
|
||||
.textBefore(text).characters
|
||||
.skipLast(1)
|
||||
.toString();
|
||||
|
||||
bloc.add(MessageTextChangedEvent(newTextBeforeCursor));
|
||||
widget.controller
|
||||
..text = newTextBeforeCursor
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: newTextBeforeCursor.length),
|
||||
);
|
||||
},
|
||||
onStickerTapped: (sticker, pack) {
|
||||
context.read<ConversationBloc>().add(
|
||||
StickerSentEvent(
|
||||
@@ -245,164 +372,14 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.emojiPickerVisible != next.emojiPickerVisible,
|
||||
builder: (context, state) => Offstage(
|
||||
offstage: !state.emojiPickerVisible,
|
||||
child: SizedBox(
|
||||
height: 250,
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (_, emoji) {
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final selection = widget.controller.selection;
|
||||
final baseOffset = max(selection.baseOffset, 0);
|
||||
final extentOffset = max(selection.extentOffset, 0);
|
||||
final prefix = bloc.state.messageText.substring(0, baseOffset);
|
||||
final suffix = bloc.state.messageText.substring(extentOffset);
|
||||
final newText = '$prefix${emoji.emoji}$suffix';
|
||||
final newValue = baseOffset + emoji.emoji.codeUnits.length;
|
||||
bloc.add(MessageTextChangedEvent(newText));
|
||||
widget.controller
|
||||
..text = newText
|
||||
..selection = TextSelection(
|
||||
baseOffset: newValue,
|
||||
extentOffset: newValue,
|
||||
);
|
||||
},
|
||||
onBackspacePressed: () {
|
||||
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final text = bloc.state.messageText;
|
||||
final selection = widget.controller.selection;
|
||||
final cursorPosition = widget.controller.selection.base.offset;
|
||||
|
||||
if (cursorPosition < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTextBeforeCursor = selection
|
||||
.textBefore(text).characters
|
||||
.skipLast(1)
|
||||
.toString();
|
||||
|
||||
bloc.add(MessageTextChangedEvent(newTextBeforeCursor));
|
||||
widget.controller
|
||||
..text = newTextBeforeCursor
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: newTextBeforeCursor.length),
|
||||
);
|
||||
},
|
||||
config: Config(
|
||||
bgColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState ||
|
||||
prev.isDragging != next.isDragging ||
|
||||
prev.isLocked != next.isLocked ||
|
||||
prev.emojiPickerVisible != next.emojiPickerVisible ||
|
||||
prev.stickerPickerVisible != next.stickerPickerVisible,
|
||||
builder: (context, state) {
|
||||
return Positioned(
|
||||
right: 8,
|
||||
bottom: state.emojiPickerVisible || state.stickerPickerVisible ?
|
||||
258 /* 8 (Regular padding) + 250 (Height of the pickers) */ :
|
||||
8,
|
||||
child: Visibility(
|
||||
visible: !state.isDragging && !state.isLocked,
|
||||
child: LongPressDraggable<int>(
|
||||
data: 1,
|
||||
axis: Axis.vertical,
|
||||
onDragStarted: () {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragStartedEvent(),
|
||||
);
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragEndedEvent(),
|
||||
);
|
||||
},
|
||||
feedback: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: FloatingActionButton(
|
||||
onPressed: null,
|
||||
heroTag: 'fabDragged',
|
||||
backgroundColor: Colors.red.shade600,
|
||||
child: BlinkingIcon(
|
||||
icon: Icons.mic,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
start: Colors.white,
|
||||
end: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
childWhenDragging: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(45),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: FloatingActionButton(
|
||||
heroTag: 'fabRest',
|
||||
onPressed: () {
|
||||
switch (state.sendButtonState) {
|
||||
case SendButtonState.audio:
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
Fluttertoast.showToast(
|
||||
msg: t.warnings.conversation.holdForLonger,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
return;
|
||||
case SendButtonState.cancelCorrection:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageEditCancelledEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
case SendButtonState.send:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageSentEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
}
|
||||
},
|
||||
child: Icon(
|
||||
_getSendButtonIcon(state),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 11,
|
||||
bottom: 8,
|
||||
right: 61,
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.isRecording != next.isRecording,
|
||||
@@ -413,7 +390,7 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
child: IgnorePointer(
|
||||
ignoring: !state.isRecording,
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
height: textFieldFontSizeConversation + 2 * 12 + 2,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(textfieldRadiusConversation),
|
||||
@@ -423,14 +400,14 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
// created and destroyed to prevent the timer from running
|
||||
// until the user closes the page.
|
||||
child: state.isRecording ?
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: TimerWidget(),
|
||||
),
|
||||
) :
|
||||
null,
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: TimerWidget(),
|
||||
),
|
||||
) :
|
||||
null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/bottom.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/bubbles/date.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/bubbles/new_device.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
||||
@@ -37,23 +38,24 @@ class ConversationPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class ConversationPageState extends State<ConversationPage> with TickerProviderStateMixin {
|
||||
ConversationPageState() :
|
||||
_controller = TextEditingController(),
|
||||
_scrollController = ScrollController(),
|
||||
_scrolledToBottomState = true,
|
||||
super();
|
||||
final TextEditingController _controller;
|
||||
final ScrollController _scrollController;
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late final AnimationController _animationController;
|
||||
late final AnimationController _overviewAnimationController;
|
||||
late final TabController _tabController;
|
||||
late Animation<double> _overviewMsgAnimation;
|
||||
late final Animation<double> _scrollToBottom;
|
||||
bool _scrolledToBottomState;
|
||||
bool _scrolledToBottomState = true;
|
||||
late FocusNode _textfieldFocus;
|
||||
final ValueNotifier<bool> _isSpeedDialOpen = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(
|
||||
length: 2,
|
||||
vsync: this,
|
||||
);
|
||||
_textfieldFocus = FocusNode();
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
@@ -75,6 +77,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_controller.dispose();
|
||||
_scrollController
|
||||
..removeListener(_onScroll)
|
||||
@@ -273,7 +276,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop(emoji.emoji);
|
||||
},
|
||||
//height: 250,
|
||||
//height: pickerHeight,
|
||||
config: Config(
|
||||
bgColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
@@ -455,15 +458,8 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
if (bloc.state.isRecording) {
|
||||
// TODO(PapaTutuWawa): Show a dialog
|
||||
return true;
|
||||
} else if (bloc.state.emojiPickerVisible) {
|
||||
bloc.add(EmojiPickerToggledEvent(handleKeyboard: false));
|
||||
|
||||
return false;
|
||||
} else if (bloc.state.stickerPickerVisible) {
|
||||
bloc.add(StickerPickerToggledEvent());
|
||||
if (_textfieldFocus.hasFocus) {
|
||||
_textfieldFocus.unfocus();
|
||||
}
|
||||
} else if (bloc.state.pickerVisible) {
|
||||
bloc.add(PickerToggledEvent(handleKeyboard: false));
|
||||
|
||||
return false;
|
||||
} else {
|
||||
@@ -507,6 +503,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -549,9 +546,16 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
),
|
||||
),
|
||||
|
||||
ConversationBottomRow(
|
||||
_controller,
|
||||
_textfieldFocus,
|
||||
ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.scaffoldBackgroundColor
|
||||
.withOpacity(0.4),
|
||||
child: ConversationBottomRow(
|
||||
_controller,
|
||||
_tabController,
|
||||
_textfieldFocus,
|
||||
_isSpeedDialOpen,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -559,12 +563,11 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.emojiPickerVisible != next.emojiPickerVisible ||
|
||||
prev.stickerPickerVisible != next.stickerPickerVisible,
|
||||
buildWhen: (prev, next) => prev.pickerVisible != next.pickerVisible,
|
||||
builder: (context, state) => Positioned(
|
||||
right: 8,
|
||||
bottom: state.emojiPickerVisible || state.stickerPickerVisible ?
|
||||
330 /* 80 + 250 */ :
|
||||
bottom: state.pickerVisible ?
|
||||
pickerHeight + 80 :
|
||||
80,
|
||||
child: Material(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0),
|
||||
@@ -594,7 +597,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
|
||||
// Indicator for the swipe to lock gesture
|
||||
Positioned(
|
||||
right: 8,
|
||||
right: 53,
|
||||
bottom: 100,
|
||||
child: IgnorePointer(
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
@@ -639,8 +642,8 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
),
|
||||
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 250,
|
||||
right: 61,
|
||||
bottom: pickerHeight,
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
builder: (context, state) {
|
||||
return DragTarget<int>(
|
||||
@@ -668,7 +671,10 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
null,
|
||||
backgroundColor: state.isLocked ?
|
||||
Colors.red.shade600 :
|
||||
Colors.grey,
|
||||
Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldColor,
|
||||
child: state.isLocked ?
|
||||
BlinkingIcon(
|
||||
icon: Icons.mic,
|
||||
@@ -676,7 +682,13 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
start: Colors.white,
|
||||
end: Colors.red.shade600,
|
||||
) :
|
||||
const Icon(Icons.lock, color: Colors.white),
|
||||
Icon(
|
||||
Icons.lock,
|
||||
color: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -687,7 +699,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
),
|
||||
|
||||
Positioned(
|
||||
right: 8,
|
||||
right: 61,
|
||||
bottom: 380,
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
builder: (context, state) {
|
||||
@@ -714,8 +726,17 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
);
|
||||
} :
|
||||
null,
|
||||
backgroundColor: Colors.grey,
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
backgroundColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldColor,
|
||||
child: Icon(
|
||||
Icons.delete,
|
||||
color: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -276,7 +276,6 @@ class ConversationsPageState extends State<ConversationsPage> with TickerProvide
|
||||
icon: Icons.chat,
|
||||
curve: Curves.bounceInOut,
|
||||
backgroundColor: primaryColor,
|
||||
// TODO(Unknown): Theme dependent?
|
||||
foregroundColor: Colors.white,
|
||||
children: [
|
||||
SpeedDialChild(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
// TODO(PapaTutuWawa): Include license text
|
||||
class SettingsAboutPage extends StatelessWidget {
|
||||
class SettingsAboutPage extends StatefulWidget {
|
||||
const SettingsAboutPage({ super.key });
|
||||
|
||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||
@@ -14,7 +18,18 @@ class SettingsAboutPage extends StatelessWidget {
|
||||
name: aboutRoute,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@override
|
||||
SettingsAboutPageState createState() => SettingsAboutPageState();
|
||||
}
|
||||
|
||||
class SettingsAboutPageState extends State<SettingsAboutPage> {
|
||||
/// The amount of taps on the Moxxy logo, if showDebugMenu is false
|
||||
int _counter = 0;
|
||||
|
||||
/// True, if the toast ("You're already a developer") has already been shown once.
|
||||
bool _alreadyShownNotificationShown = false;
|
||||
|
||||
Future<void> _openUrl(String url) async {
|
||||
if (!await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication)) {
|
||||
// TODO(Unknown): Show a popup to copy the url
|
||||
@@ -29,9 +44,45 @@ class SettingsAboutPage extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 200, height: 200,
|
||||
BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu,
|
||||
builder: (context, state) => InkWell(
|
||||
onTap: () async {
|
||||
if (state.showDebugMenu) {
|
||||
if (_counter == 0 && !_alreadyShownNotificationShown) {
|
||||
_alreadyShownNotificationShown = true;
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.pages.settings.about.debugMenuAlreadyShown,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_counter++;
|
||||
if (_counter == 10) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(
|
||||
showDebugMenu: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.pages.settings.about.debugMenuShown,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
}
|
||||
},
|
||||
child:Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 200, height: 200,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
t.global.title,
|
||||
@@ -55,7 +106,7 @@ class SettingsAboutPage extends StatelessWidget {
|
||||
child: Text(
|
||||
// TODO(Unknown): Generate this at build time
|
||||
t.pages.settings.about.version(
|
||||
version: '0.4.0',
|
||||
version: '0.4.1',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
@@ -135,6 +136,23 @@ class DebuggingPage extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Hide the testing commands outside of debug mode
|
||||
...kDebugMode ? [
|
||||
const SectionTitle('Testing'),
|
||||
SettingsRow(
|
||||
title: 'Reset showDebugMenu state',
|
||||
onTap: () {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(
|
||||
showDebugMenu: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
] : [],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
@@ -25,129 +27,134 @@ class SettingsPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.settings.title),
|
||||
body: ListView(
|
||||
children: [
|
||||
SectionTitle(t.pages.settings.settings.conversationsSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.settings.conversationsSection,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.chat_bubble),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, conversationSettingsRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.stickers.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(PhosphorIcons.stickerBold),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, stickersRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.network.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.network_wifi),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, networkRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.privacy.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.shield),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, privacyRoute);
|
||||
},
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.settings.accountSection),
|
||||
SettingsRow(
|
||||
title: t.pages.blocklist.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.block),
|
||||
),
|
||||
onTap: () {
|
||||
GetIt.I.get<BlocklistBloc>().add(
|
||||
BlocklistRequestedEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.settings.signOut,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.logout),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.settings.settings.signOutConfirmTitle,
|
||||
t.pages.settings.settings.signOutConfirmBody,
|
||||
context,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
GetIt.I.get<PreferencesBloc>().add(SignedOutEvent());
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.settings.miscellaneousSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.appearance.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.logout),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, appearanceRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.about.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.info),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, aboutRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.licenses.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.info),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, licensesRoute);
|
||||
},
|
||||
),
|
||||
|
||||
if (kDebugMode)
|
||||
SectionTitle(t.pages.settings.settings.debuggingSection),
|
||||
|
||||
if (kDebugMode)
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu,
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
SectionTitle(t.pages.settings.settings.general),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.debugging.title,
|
||||
title: t.pages.settings.appearance.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.brush),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, appearanceRoute);
|
||||
},
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.settings.conversationsSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.settings.conversationsSection,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.chat_bubble),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, conversationSettingsRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.stickers.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(PhosphorIcons.stickerBold),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, stickersRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.network.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.network_wifi),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, networkRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.privacy.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.shield),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, privacyRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.blocklist.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.block),
|
||||
),
|
||||
onTap: () {
|
||||
GetIt.I.get<BlocklistBloc>().add(
|
||||
BlocklistRequestedEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.settings.accountSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.settings.signOut,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.logout),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.settings.settings.signOutConfirmTitle,
|
||||
t.pages.settings.settings.signOutConfirmBody,
|
||||
context,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
GetIt.I.get<PreferencesBloc>().add(SignedOutEvent());
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.settings.miscellaneousSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.about.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.info),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, debuggingRoute);
|
||||
Navigator.pushNamed(context, aboutRoute);
|
||||
},
|
||||
),
|
||||
],
|
||||
SettingsRow(
|
||||
title: t.pages.settings.licenses.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.info),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, licensesRoute);
|
||||
},
|
||||
),
|
||||
|
||||
if (kDebugMode || state.showDebugMenu)
|
||||
SectionTitle(t.pages.settings.settings.debuggingSection),
|
||||
|
||||
if (kDebugMode || state.showDebugMenu)
|
||||
SettingsRow(
|
||||
title: t.pages.settings.debugging.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.info),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, debuggingRoute);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ class StickersSettingsPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.stickers.stickerPacksSection),
|
||||
|
||||
if (stickers.stickerPacks.isEmpty)
|
||||
SettingsRow(
|
||||
title: t.pages.conversation.stickerPickerNoStickersLine1,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||
|
||||
/// Handler for when we received a [PreStartDoneEvent].
|
||||
Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
@@ -53,12 +53,19 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
result.stickers!,
|
||||
),
|
||||
);
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionInitEvent(
|
||||
result.conversations!,
|
||||
result.roster!,
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<Logger>().finest('Navigating to conversations');
|
||||
|
||||
// Only go to the Conversations page when we did not start due to a sharing intent
|
||||
final handler = ShareHandlerPlatform.instance;
|
||||
if (await handler.getInitialSharedMedia() == null) {
|
||||
final sharing = GetIt.I.get<UISharingService>();
|
||||
if (sharing.hasEarlyMedia) {
|
||||
GetIt.I.get<Logger>().finest('Early media available. Navigating to share selection');
|
||||
await sharing.handleEarlySharedMedia();
|
||||
} else {
|
||||
GetIt.I.get<Logger>().finest('Navigating to conversations');
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
const NavigationDestination(conversationsRoute),
|
||||
@@ -66,15 +73,14 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionInitEvent(
|
||||
result.conversations!,
|
||||
result.roster!,
|
||||
),
|
||||
);
|
||||
} else if (result.state == preStartNotLoggedInState) {
|
||||
// Set UI data
|
||||
GetIt.I.get<UIDataService>().isLoggedIn = false;
|
||||
|
||||
// Clear shared media data
|
||||
await GetIt.I.get<UISharingService>().clearSharedMedia();
|
||||
|
||||
// Navigate to the intro page
|
||||
GetIt.I.get<Logger>().finest('Navigating to intro');
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
|
||||
75
lib/ui/service/sharing.dart
Normal file
75
lib/ui/service/sharing.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
/// This service is responsible for storing a sharing request and or executing it.
|
||||
class UISharingService {
|
||||
/// A possible media object that was shared to Moxxy while the app was closed.
|
||||
SharedMedia? _media;
|
||||
|
||||
/// Flag indicating whether the service has already been initialized or not.
|
||||
bool _initialized = false;
|
||||
|
||||
/// Logger
|
||||
final Logger _log = Logger('UISharingService');
|
||||
|
||||
/// If [media] is non-null, forwards the metadata to the ShareSelectionBloc, which
|
||||
/// will open the share dialog.
|
||||
/// If [media] is null, then nothing will happen.
|
||||
Future<void> _handleSharedMedia(SharedMedia? media) async {
|
||||
if (media == null) return;
|
||||
|
||||
_log.finest('Handling media');
|
||||
final attachments = media.attachments ?? [];
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionRequestedEvent(
|
||||
attachments.map((a) => a!.path).toList(),
|
||||
media.content,
|
||||
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
|
||||
),
|
||||
);
|
||||
|
||||
await clearSharedMedia();
|
||||
}
|
||||
|
||||
/// Clears all shared media data we (and the share_handler plugin has) have.
|
||||
Future<void> clearSharedMedia() async {
|
||||
_log.finest('Clearing media');
|
||||
await ShareHandlerPlatform.instance.resetInitialSharedMedia();
|
||||
_media = null;
|
||||
}
|
||||
|
||||
/// True if we have early media. False if not.
|
||||
bool get hasEarlyMedia => _media != null;
|
||||
|
||||
/// If Moxxy was started with a share intent, then this function is equivalent to
|
||||
/// [UISharingService._handleSharedMedia] but called with said share intent's metadata.
|
||||
Future<void> handleEarlySharedMedia() async {
|
||||
await _handleSharedMedia(_media);
|
||||
}
|
||||
|
||||
/// Sets up streams for reacting to share intents. Also stores an initial shared media
|
||||
/// object for later retrieval, if available.
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
final media = await ShareHandlerPlatform.instance.getInitialSharedMedia();
|
||||
if (media != null) {
|
||||
_log.finest('initialize: Early media is not null');
|
||||
_media = media;
|
||||
}
|
||||
|
||||
ShareHandlerPlatform.instance.sharedMediaStream.listen((SharedMedia media) async {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
_log.finest('stream: Handle shared media via stream');
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await ShareHandlerPlatform.instance.resetInitialSharedMedia();
|
||||
});
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
|
||||
/// A theme extension for Moxxy specific colors.
|
||||
@immutable
|
||||
class MoxxyThemeData extends ThemeExtension<MoxxyThemeData> {
|
||||
const MoxxyThemeData({
|
||||
required this.conversationTextFieldColor,
|
||||
required this.profileFallbackBackgroundColor,
|
||||
required this.profileFallbackTextColor,
|
||||
required this.bubbleQuoteInTextFieldColor,
|
||||
required this.bubbleQuoteInTextFieldTextColor,
|
||||
required this.conversationTextFieldHintTextColor,
|
||||
required this.conversationTextFieldTextColor,
|
||||
});
|
||||
|
||||
/// The color of the conversation TextField
|
||||
final Color conversationTextFieldColor;
|
||||
|
||||
/// The color of the background of a user with no avatar
|
||||
final Color profileFallbackBackgroundColor;
|
||||
|
||||
/// The text color of a user with no avatar
|
||||
final Color profileFallbackTextColor;
|
||||
|
||||
/// The color of a quote bubble displayed inside the TextField
|
||||
final Color bubbleQuoteInTextFieldColor;
|
||||
|
||||
/// The color of text inside a quote bubble inside the TextField
|
||||
final Color bubbleQuoteInTextFieldTextColor;
|
||||
|
||||
/// The color of the hint text inside the TextField of the ConversationPage
|
||||
final Color conversationTextFieldHintTextColor;
|
||||
|
||||
/// The regular text color of the message TextField on the ConversationPage
|
||||
final Color conversationTextFieldTextColor;
|
||||
|
||||
@override
|
||||
MoxxyThemeData copyWith({Color? conversationTextFieldColor, Color? profileFallbackBackgroundColor, Color? profileFallbackTextColor, Color? bubbleQuoteInTextFieldColor, Color? bubbleQuoteInTextFieldTextColor, Color? conversationTextFieldHintTextColor, Color? conversationTextFieldTextColor,}) {
|
||||
return MoxxyThemeData(
|
||||
conversationTextFieldColor: conversationTextFieldColor ?? this.conversationTextFieldColor,
|
||||
profileFallbackBackgroundColor: profileFallbackBackgroundColor ?? this.profileFallbackBackgroundColor,
|
||||
profileFallbackTextColor: profileFallbackTextColor ?? this.profileFallbackTextColor,
|
||||
bubbleQuoteInTextFieldColor: bubbleQuoteInTextFieldColor ?? this.bubbleQuoteInTextFieldColor,
|
||||
bubbleQuoteInTextFieldTextColor: bubbleQuoteInTextFieldTextColor ?? this.bubbleQuoteInTextFieldTextColor,
|
||||
conversationTextFieldHintTextColor: conversationTextFieldHintTextColor ?? this.conversationTextFieldHintTextColor,
|
||||
conversationTextFieldTextColor: conversationTextFieldTextColor ?? this.conversationTextFieldTextColor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
MoxxyThemeData lerp(ThemeExtension<MoxxyThemeData>? other, double t) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function for quickly generating MaterialStateProperty instances that
|
||||
/// only differentiate between a color for the element's disabled state and for all
|
||||
/// other states.
|
||||
@@ -52,5 +105,28 @@ ThemeData getThemeData(BuildContext context, Brightness brightness) {
|
||||
return primaryColor;
|
||||
}),
|
||||
),
|
||||
|
||||
extensions: [
|
||||
if (brightness == Brightness.dark)
|
||||
const MoxxyThemeData(
|
||||
conversationTextFieldColor: conversationTextFieldColorDark,
|
||||
profileFallbackBackgroundColor: profileFallbackBackgroundColorDark,
|
||||
profileFallbackTextColor: profileFallbackTextColorDark,
|
||||
bubbleQuoteInTextFieldColor: bubbleQuoteInTextFieldColorDark,
|
||||
bubbleQuoteInTextFieldTextColor: bubbleQuoteInTextFieldTextColorDark,
|
||||
conversationTextFieldHintTextColor: textFieldHintTextColorDark,
|
||||
conversationTextFieldTextColor: textFieldTextColorDark,
|
||||
)
|
||||
else
|
||||
const MoxxyThemeData(
|
||||
conversationTextFieldColor: conversationTextFieldColorLight,
|
||||
profileFallbackBackgroundColor: profileFallbackBackgroundColorLight,
|
||||
profileFallbackTextColor: profileFallbackTextColorLight,
|
||||
bubbleQuoteInTextFieldColor: bubbleQuoteInTextFieldColorLight,
|
||||
bubbleQuoteInTextFieldTextColor: bubbleQuoteInTextFieldTextColorLight,
|
||||
conversationTextFieldHintTextColor: textFieldHintTextColorLight,
|
||||
conversationTextFieldTextColor: textFieldTextColorLight,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
class AvatarWrapper extends StatelessWidget {
|
||||
const AvatarWrapper({ required this.radius, this.avatarUrl, this.altText, this.altIcon, this.onTapFunction, this.showEditButton = false, super.key })
|
||||
@@ -14,12 +14,13 @@ class AvatarWrapper extends StatelessWidget {
|
||||
final bool showEditButton;
|
||||
final void Function()? onTapFunction;
|
||||
|
||||
Widget _constructAlt() {
|
||||
Widget _constructAlt(BuildContext context) {
|
||||
if (altText != null) {
|
||||
return Text(
|
||||
avatarAltText(altText!),
|
||||
style: TextStyle(
|
||||
fontSize: radius * 0.8,
|
||||
color: Theme.of(context).extension<MoxxyThemeData>()!.profileFallbackTextColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -27,25 +28,26 @@ class AvatarWrapper extends StatelessWidget {
|
||||
return Icon(
|
||||
altIcon,
|
||||
size: radius,
|
||||
color: Theme.of(context).extension<MoxxyThemeData>()!.profileFallbackTextColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Either display the alt or the actual image
|
||||
Widget _avatarWrapper() {
|
||||
Widget _avatarWrapper(BuildContext context) {
|
||||
final useAlt = avatarUrl == null || avatarUrl == '';
|
||||
|
||||
return CircleAvatar(
|
||||
backgroundColor: Colors.grey[800],
|
||||
backgroundColor: Theme.of(context).extension<MoxxyThemeData>()!.profileFallbackBackgroundColor,
|
||||
backgroundImage: !useAlt ? FileImage(File(avatarUrl!)) : null,
|
||||
radius: radius,
|
||||
child: useAlt ? _constructAlt() : null,
|
||||
child: useAlt ? _constructAlt(context) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _withEditButton() {
|
||||
Widget _withEditButton(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
_avatarWrapper(),
|
||||
_avatarWrapper(context),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
@@ -71,7 +73,9 @@ class AvatarWrapper extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTapFunction,
|
||||
child: showEditButton ? _withEditButton() : _avatarWrapper(),
|
||||
child: showEditButton ?
|
||||
_withEditButton(context) :
|
||||
_avatarWrapper(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ class DateBubble extends StatelessWidget {
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(value),
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -254,17 +254,16 @@ class AudioChatState extends State<AudioChatWidget> {
|
||||
}
|
||||
|
||||
Widget _buildDownloadable() {
|
||||
// TODO(Unknown): Implement
|
||||
return FileChatBaseWidget(
|
||||
widget.message,
|
||||
Icons.image,
|
||||
widget.message.isFileUploadNotification ?
|
||||
(widget.message.filename ?? '') :
|
||||
filenameFromUrl(widget.message.srcUrl!),
|
||||
widget.radius,
|
||||
widget.maxWidth,
|
||||
widget.sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: widget.message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: widget.message),
|
||||
|
||||
@@ -14,25 +14,39 @@ import 'package:moxxyv2/ui/widgets/chat/progress.dart';
|
||||
class FileChatBaseWidget extends StatelessWidget {
|
||||
const FileChatBaseWidget(
|
||||
this.message,
|
||||
this.icon,
|
||||
this.filename,
|
||||
this.radius,
|
||||
this.maxWidth,
|
||||
this.sent,
|
||||
{
|
||||
this.extra,
|
||||
this.downloadButton,
|
||||
this.onTap,
|
||||
this.mimeType,
|
||||
super.key,
|
||||
}
|
||||
);
|
||||
final Message message;
|
||||
final IconData icon;
|
||||
final String filename;
|
||||
final BorderRadius radius;
|
||||
final double maxWidth;
|
||||
final Widget? extra;
|
||||
final Widget? downloadButton;
|
||||
final bool sent;
|
||||
final void Function()? onTap;
|
||||
final String? mimeType;
|
||||
|
||||
IconData _mimeTypeToIcon() {
|
||||
if (mimeType == null) return Icons.file_present;
|
||||
|
||||
if (mimeType!.startsWith('image/')) {
|
||||
return Icons.image;
|
||||
} else if (mimeType!.startsWith('video/')) {
|
||||
return Icons.video_file_outlined;
|
||||
} else if (mimeType!.startsWith('audio/')) {
|
||||
return Icons.music_note;
|
||||
}
|
||||
|
||||
return Icons.file_present;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -40,20 +54,43 @@ class FileChatBaseWidget extends StatelessWidget {
|
||||
width: maxWidth,
|
||||
child: MediaBaseChatWidget(
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 48,
|
||||
),
|
||||
if (downloadButton != null)
|
||||
downloadButton!,
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: Text(
|
||||
filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_mimeTypeToIcon(),
|
||||
size: 48,
|
||||
),
|
||||
|
||||
Text(
|
||||
mimeTypeToName(mimeType),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -62,7 +99,7 @@ class FileChatBaseWidget extends StatelessWidget {
|
||||
MessageBubbleBottom(message, sent),
|
||||
radius,
|
||||
gradient: false,
|
||||
extra: extra,
|
||||
//extra: extra,
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
@@ -91,14 +128,14 @@ class FileChatWidget extends StatelessWidget {
|
||||
Widget _buildNonDownloaded() {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.file_present,
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: message),
|
||||
@@ -112,25 +149,27 @@ class FileChatWidget extends StatelessWidget {
|
||||
Widget _buildDownloading() {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.file_present,
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl ?? ''),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: ProgressWidget(id: message.id),
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInner() {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.file_present,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
mimeType: message.mediaType,
|
||||
onTap: () {
|
||||
openFile(message.mediaUrl!);
|
||||
},
|
||||
|
||||
@@ -58,12 +58,14 @@ class ImageChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.image,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: ProgressWidget(id: message.id),
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,12 +116,14 @@ class ImageChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.image,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: message),
|
||||
|
||||
@@ -69,12 +69,14 @@ class VideoChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.video_file_outlined,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: ProgressWidget(id: message.id),
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,12 +124,14 @@ class VideoChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.video_file_outlined,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: message),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
/// This Widget is used to show that a message has been quoted.
|
||||
class QuoteBaseWidget extends StatelessWidget {
|
||||
@@ -20,7 +21,11 @@ class QuoteBaseWidget extends StatelessWidget {
|
||||
final bool sent;
|
||||
final void Function()? resetQuotedMessage;
|
||||
|
||||
Color _getColor() {
|
||||
Color _getColor(BuildContext context) {
|
||||
if (resetQuotedMessage != null) {
|
||||
return Theme.of(context).extension<MoxxyThemeData>()!.bubbleQuoteInTextFieldColor;
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return bubbleColorSentQuoted;
|
||||
} else {
|
||||
@@ -30,7 +35,6 @@ class QuoteBaseWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const quoteLeftBorderWidth = 7.0;
|
||||
EdgeInsetsGeometry padding = const EdgeInsets.only(
|
||||
left: 8 + quoteLeftBorderWidth,
|
||||
right: 8,
|
||||
@@ -45,39 +49,42 @@ class QuoteBaseWidget extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Material(
|
||||
color: _getColor(),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(radiusLarge),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
width: quoteLeftBorderWidth,
|
||||
),
|
||||
),
|
||||
|
||||
if (resetQuotedMessage != null)
|
||||
Positioned(
|
||||
right: 3,
|
||||
top: 3,
|
||||
child: IconButton(
|
||||
onPressed: resetQuotedMessage,
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
size: 24,
|
||||
),
|
||||
child: Material(
|
||||
color: _getColor(context),
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: Colors.white,
|
||||
width: quoteLeftBorderWidth,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
|
||||
if (resetQuotedMessage != null)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
onPressed: resetQuotedMessage,
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
52
lib/ui/widgets/chat/quote/helpers.dart
Normal file
52
lib/ui/widgets/chat/quote/helpers.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
class QuoteSenderText extends StatelessWidget {
|
||||
const QuoteSenderText({
|
||||
required this.sender,
|
||||
required this.resetQuoteNotNull,
|
||||
required this.sent,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The sender JID of the quoted message.
|
||||
final String sender;
|
||||
|
||||
/// True if resetQuote is not null.
|
||||
final bool resetQuoteNotNull;
|
||||
|
||||
/// The sent attribute passed to the quote widget.
|
||||
final bool sent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sentBySelf = resetQuoteNotNull ?
|
||||
sent :
|
||||
sender == GetIt.I.get<UIDataService>().ownJid;
|
||||
|
||||
return Text(
|
||||
sentBySelf ?
|
||||
t.messages.you :
|
||||
GetIt.I.get<ConversationBloc>().state.conversation!.titleWithOptionalContact,
|
||||
style: const TextStyle(
|
||||
color: bubbleTextQuoteSenderColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Figures out the best text color for quotes. [context] is the surrounding
|
||||
/// BuildContext. [insideTextField] is true if the quote is used as a widget inside
|
||||
/// the TextField.
|
||||
Color getQuoteTextColor(BuildContext context, bool insideTextField) {
|
||||
if (!insideTextField) return bubbleTextQuoteColor;
|
||||
|
||||
return Theme.of(context).extension<MoxxyThemeData>()!.bubbleQuoteInTextFieldTextColor;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/helpers.dart';
|
||||
|
||||
class QuotedMediaBaseWidget extends StatelessWidget {
|
||||
const QuotedMediaBaseWidget(
|
||||
@@ -28,7 +29,24 @@ class QuotedMediaBaseWidget extends StatelessWidget {
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(text),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
QuoteSenderText(
|
||||
sender: message.sender,
|
||||
resetQuoteNotNull: resetQuote != null,
|
||||
sent: sent,
|
||||
),
|
||||
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: getQuoteTextColor(context, resetQuote != null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/helpers.dart';
|
||||
|
||||
class QuotedTextWidget extends StatelessWidget {
|
||||
const QuotedTextWidget(
|
||||
@@ -14,16 +14,28 @@ class QuotedTextWidget extends StatelessWidget {
|
||||
final Message message;
|
||||
final bool sent;
|
||||
final void Function()? resetQuote;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuoteBaseWidget(
|
||||
message,
|
||||
Text(
|
||||
message.body,
|
||||
style: const TextStyle(
|
||||
color: bubbleTextColor,
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
QuoteSenderText(
|
||||
sender: message.sender,
|
||||
resetQuoteNotNull: resetQuote != null,
|
||||
sent: sent,
|
||||
),
|
||||
|
||||
Text(
|
||||
message.body,
|
||||
style: TextStyle(
|
||||
color: getQuoteTextColor(context, resetQuote != null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
sent,
|
||||
resetQuotedMessage: resetQuote,
|
||||
|
||||
79
lib/ui/widgets/combined_picker.dart
Normal file
79
lib/ui/widgets/combined_picker.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/sticker_picker.dart';
|
||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
|
||||
class CombinedPicker extends StatelessWidget {
|
||||
const CombinedPicker({
|
||||
required this.tabController,
|
||||
required this.onEmojiTapped,
|
||||
required this.onBackspaceTapped,
|
||||
required this.onStickerTapped,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The controlling tab controller
|
||||
final TabController tabController;
|
||||
|
||||
/// Called when an emoji has been tapped from the list.
|
||||
final void Function(Emoji) onEmojiTapped;
|
||||
|
||||
/// Called when the backspace button has been tapped
|
||||
final void Function() onBackspaceTapped;
|
||||
|
||||
/// Called when a sticker has been tapped
|
||||
final void Function(Sticker, StickerPack) onStickerTapped;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StickersBloc, StickersState>(
|
||||
builder: (context, state) {
|
||||
final scaffoldColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return SizedBox(
|
||||
height: pickerHeight,
|
||||
width: width,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
indicatorColor: primaryColor,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.insert_emoticon)),
|
||||
Tab(icon: Icon(PhosphorIcons.stickerBold)),
|
||||
],
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
EmojiPicker(
|
||||
onEmojiSelected: (_, emoji) => onEmojiTapped(emoji),
|
||||
onBackspacePressed: onBackspaceTapped,
|
||||
config: Config(
|
||||
bgColor: scaffoldColor,
|
||||
),
|
||||
),
|
||||
StickerPicker(
|
||||
width: width,
|
||||
onStickerTapped: onStickerTapped,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -154,19 +154,27 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
);
|
||||
}
|
||||
} else if (widget.conversation.lastMessage!.mediaType!.startsWith('image/')) {
|
||||
preview = SharedImageWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
if (widget.conversation.lastMessage!.mediaUrl == null) {
|
||||
preview = const SizedBox();
|
||||
} else {
|
||||
preview = SharedImageWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
}
|
||||
} else if (widget.conversation.lastMessage!.mediaType!.startsWith('video/')) {
|
||||
preview = SharedVideoWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
widget.conversation.jid,
|
||||
widget.conversation.lastMessage!.mediaType!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
if (widget.conversation.lastMessage!.mediaUrl == null) {
|
||||
preview = const SizedBox();
|
||||
} else {
|
||||
preview = SharedVideoWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
widget.conversation.jid,
|
||||
widget.conversation.lastMessage!.mediaType!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
|
||||
@@ -131,17 +131,9 @@ class StickerPicker extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StickersBloc, StickersState>(
|
||||
builder: (context, state) {
|
||||
|
||||
return SizedBox(
|
||||
height: 250,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: _buildList(context, state),
|
||||
),
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: _buildList(context, state),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ class CustomTextField extends StatelessWidget {
|
||||
this.errorText,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.hintTextColor,
|
||||
this.suffix,
|
||||
this.suffixText,
|
||||
this.topWidget,
|
||||
@@ -31,6 +32,7 @@ class CustomTextField extends StatelessWidget {
|
||||
this.onTap,
|
||||
this.shouldSummonKeyboard,
|
||||
this.focusNode,
|
||||
this.fontSize,
|
||||
super.key,
|
||||
});
|
||||
final double cornerRadius;
|
||||
@@ -54,8 +56,10 @@ class CustomTextField extends StatelessWidget {
|
||||
final int minLines;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
final Color? hintTextColor;
|
||||
final double? borderWidth;
|
||||
final Color? borderColor;
|
||||
final double? fontSize;
|
||||
final TextEditingController? controller;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final void Function()? onTap;
|
||||
@@ -64,7 +68,12 @@ class CustomTextField extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = textColor != null ? TextStyle(color: textColor) : null;
|
||||
final style = textColor != null ?
|
||||
TextStyle(
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
) :
|
||||
null;
|
||||
return Column(
|
||||
children: [
|
||||
DecoratedBox(
|
||||
@@ -105,7 +114,10 @@ class CustomTextField extends StatelessWidget {
|
||||
isDense: isDense,
|
||||
labelStyle: style,
|
||||
suffixStyle: style,
|
||||
hintStyle: style,
|
||||
hintStyle: TextStyle(
|
||||
color: hintTextColor,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
prefixIcon: prefixIcon,
|
||||
prefixIconConstraints: prefixIconConstraints,
|
||||
suffixIconConstraints: suffixIconConstraints,
|
||||
|
||||
21
pubspec.lock
21
pubspec.lock
@@ -337,13 +337,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
emoji_picker_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -836,16 +829,18 @@ packages:
|
||||
description:
|
||||
path: "packages/moxxmpp"
|
||||
ref: HEAD
|
||||
resolved-ref: "55d2ef9c25d383806bc780597a96d3c2887a4aa1"
|
||||
resolved-ref: "6c63b53cf4870bc1303b1a2df835d5b67a9b88c4"
|
||||
url: "https://git.polynom.me/Moxxy/moxxmpp.git"
|
||||
source: git
|
||||
version: "0.1.6+1"
|
||||
moxxmpp_socket_tcp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: moxxmpp_socket_tcp
|
||||
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
|
||||
source: hosted
|
||||
path: "packages/moxxmpp_socket_tcp"
|
||||
ref: HEAD
|
||||
resolved-ref: a8d80eaddf8784532d88442d74202a47af7de047
|
||||
url: "https://git.polynom.me/Moxxy/moxxmpp.git"
|
||||
source: git
|
||||
version: "0.1.2+9"
|
||||
moxxyv2_builders:
|
||||
dependency: "direct main"
|
||||
@@ -888,7 +883,7 @@ packages:
|
||||
name: omemo_dart
|
||||
url: "https://git.polynom.me/api/packages/PapaTutuWawa/pub/"
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
version: "0.4.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1597,5 +1592,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
sdks:
|
||||
dart: ">=2.17.5 <3.0.0"
|
||||
dart: ">=2.17.0 <3.0.0"
|
||||
flutter: ">=3.3.8"
|
||||
|
||||
14
pubspec.yaml
14
pubspec.yaml
@@ -3,7 +3,7 @@ description: An experimental XMPP client
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.4.0+8
|
||||
version: 0.4.1+9
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -23,7 +23,6 @@ dependencies:
|
||||
#cupertino_icons: 1.0.2
|
||||
dart_emoji: 0.2.0+2
|
||||
decorated_icon: 1.2.1
|
||||
dio: 4.0.6
|
||||
emoji_picker_flutter: 1.3.1
|
||||
external_path: 1.0.1
|
||||
file_picker: 5.0.1
|
||||
@@ -75,7 +74,7 @@ dependencies:
|
||||
native_imaging: 0.1.0
|
||||
omemo_dart:
|
||||
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
|
||||
version: 0.4.1
|
||||
version: 0.4.2
|
||||
page_transition: 2.0.9
|
||||
path: 1.8.2
|
||||
path_provider: 2.0.11
|
||||
@@ -134,14 +133,21 @@ dependency_overrides:
|
||||
# NOTE: Leave here for development purposes
|
||||
# moxxmpp:
|
||||
# path: ../moxxmpp/packages/moxxmpp
|
||||
# moxxmpp_socket_tcp:
|
||||
# path: ../moxxmpp/packages/moxxmpp_socket_tcp
|
||||
# omemo_dart:
|
||||
# path: ../../Personal/omemo_dart
|
||||
|
||||
moxxmpp:
|
||||
git:
|
||||
url: https://git.polynom.me/Moxxy/moxxmpp.git
|
||||
rev: 55d2ef9c25d383806bc780597a96d3c2887a4aa1
|
||||
rev: 6c63b53cf4870bc1303b1a2df835d5b67a9b88c4
|
||||
path: packages/moxxmpp
|
||||
moxxmpp_socket_tcp:
|
||||
git:
|
||||
url: https://git.polynom.me/Moxxy/moxxmpp.git
|
||||
rev: 1aa50699adfa975831a52c737a7c63c54083bd9c
|
||||
path: packages/moxxmpp_socket_tcp
|
||||
|
||||
extra_licenses:
|
||||
- name: undraw.co
|
||||
|
||||
17
scripts/build.sh
Normal file
17
scripts/build.sh
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
[[ $1 = "--clean" ]] && flutter clean
|
||||
|
||||
# Build everything again
|
||||
flutter pub run build_runner build
|
||||
|
||||
# Build the release apk
|
||||
flutter build apk \
|
||||
--release \
|
||||
--split-per-abi
|
||||
|
||||
# Create a folder with releases
|
||||
[[ -d ./release ]] && rm -rf ./release
|
||||
mkdir release
|
||||
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ./release/moxxy-arm64-v8a-release.apk
|
||||
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ./release/moxxy-armeabi-v7a-release.apk
|
||||
cp build/app/outputs/flutter-apk/app-x86_64-release.apk ./release/moxxy-x86_64-release.apk
|
||||
Reference in New Issue
Block a user