diff --git a/lib/main.dart b/lib/main.dart index 159b535..fb0f3ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,10 @@ import 'package:flutter/foundation.dart'; +import 'package:provider/provider.dart'; import 'package:twonly/src/providers/api_provider.dart'; import 'package:twonly/src/providers/db_provider.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:twonly/src/providers/notify_provider.dart'; import 'package:twonly/src/utils/misc.dart'; import 'src/app.dart'; import 'src/settings/settings_controller.dart'; @@ -49,5 +51,12 @@ void main() async { // return true; // }); - runApp(MyApp(settingsController: settingsController)); + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => NotifyProvider()), + ], + child: MyApp(settingsController: settingsController), + ), + ); } diff --git a/lib/src/app.dart b/lib/src/app.dart index 8905437..aa076af 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -1,4 +1,6 @@ +import 'package:provider/provider.dart'; import 'package:twonly/main.dart'; +import 'package:twonly/src/providers/notify_provider.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/onboarding_view.dart'; import 'package:twonly/src/views/home_view.dart'; @@ -30,13 +32,18 @@ class _MyAppState extends State { @override void initState() { super.initState(); - // Start the color animation _startColorAnimation(); + apiProvider.setConnectionStateCallback((isConnected) { setState(() { _isConnected = isConnected; }); }); + apiProvider.setUpdatedContacts(() { + context.read().update(); + }); + + context.read().update(); apiProvider.connect(); } diff --git a/lib/src/components/initialsavatar_component.dart b/lib/src/components/initialsavatar.dart similarity index 100% rename from lib/src/components/initialsavatar_component.dart rename to lib/src/components/initialsavatar.dart diff --git a/lib/src/components/message_send_state_icon.dart b/lib/src/components/message_send_state_icon.dart new file mode 100644 index 0000000..5d2e63f --- /dev/null +++ b/lib/src/components/message_send_state_icon.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +enum MessageSendState { + opened, + received, + send, + sending, +} + +class MessageSendStateIcon extends StatelessWidget { + final MessageSendState state; + + const MessageSendStateIcon({super.key, required this.state}); + + @override + Widget build(BuildContext context) { + Widget icon = Placeholder(); + String text = ""; + + switch (state) { + case MessageSendState.opened: + icon = Icon( + Icons.crop_square, + size: 14, + color: Theme.of(context).colorScheme.primary, + ); + text = "Opened"; + break; + case MessageSendState.received: + icon = Icon( + Icons.square_rounded, + size: 14, + color: Theme.of(context).colorScheme.primary, + ); + text = "Received"; + break; + case MessageSendState.send: + icon = Icon( + Icons.send, + size: 14, + ); + text = "Send"; + break; + case MessageSendState.sending: + icon = Row( + children: [ + SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + strokeWidth: 1, + ), + ), + SizedBox(width: 2), + ], + ); + text = "Sending"; + break; + } + + return Row( + children: [ + icon, + const SizedBox(width: 3), + Text(text, style: TextStyle(fontSize: 12)), + const SizedBox(width: 5), + ], + ); + } +} diff --git a/lib/src/components/notification_badge.dart b/lib/src/components/notification_badge.dart new file mode 100644 index 0000000..675ed50 --- /dev/null +++ b/lib/src/components/notification_badge.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class NotificationBadge extends StatelessWidget { + final int count; + final Widget child; + + const NotificationBadge( + {super.key, required this.count, required this.child}); + + @override + Widget build(BuildContext context) { + if (count == 0) return child; + return Stack( + children: [ + child, + Positioned( + right: 5, + top: 0, + child: Container( + padding: EdgeInsets.all(5.0), // Add some padding + decoration: BoxDecoration( + color: Colors.red, // Background color + shape: BoxShape.circle, // Make it circular + ), + child: Center( + child: Text( + count.toString(), + style: TextStyle( + color: Colors.white, // Text color + fontSize: 10, + ), + ), + ), + ), + ) + ], + ); + } +} diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index aa49b6f..16d8400 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -16,6 +16,8 @@ "searchUsernameInput": "Username", "searchUsernameTitle": "Search username", "searchUsernameNotFound": "Username not found", + "searchUsernameNewFollowerTitle": "Follow requests", + "searchUsernameQrCodeBtn": "Scan QR code", "searchUsernameNotFoundLong": "\"{username}\" is not a twonly user. Please check the username and try again.", "errorUnknown": "An unexpected error has occurred. Please try again later.", "errorBadRequest": "The request could not be understood by the server due to malformed syntax. Please check your input and try again.", diff --git a/lib/src/model/contacts_model.dart b/lib/src/model/contacts_model.dart index e53c326..a6d4157 100644 --- a/lib/src/model/contacts_model.dart +++ b/lib/src/model/contacts_model.dart @@ -30,6 +30,9 @@ class DbContacts extends CvModelBase { static const columnRequested = "requested"; final requested = CvField(columnRequested); + static const columnBlocked = "blocked"; + final blocked = CvField(columnBlocked); + static const columnCreatedAt = "created_at"; final createdAt = CvField(columnCreatedAt); @@ -40,6 +43,7 @@ class DbContacts extends CvModelBase { $columnDisplayName TEXT, $columnAccepted INT NOT NULL DEFAULT 0, $columnRequested INT NOT NULL DEFAULT 0, + $columnBlocked INT NOT NULL DEFAULT 0, $columnCreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ) """; @@ -47,22 +51,23 @@ class DbContacts extends CvModelBase { @override List get fields => - [userId, displayName, accepted, requested, createdAt]; + [userId, displayName, accepted, requested, blocked, createdAt]; static Future> getUsers() async { try { - var users = await dbProvider.db!.query(tableName, columns: [ - columnUserId, - columnDisplayName, - columnAccepted, - columnRequested, - columnCreatedAt - ]); + var users = await dbProvider.db!.query(tableName, + columns: [ + columnUserId, + columnDisplayName, + columnAccepted, + columnRequested, + columnCreatedAt + ], + where: "$columnBlocked = 0"); if (users.isEmpty) return []; List parsedUsers = []; for (int i = 0; i < users.length; i++) { - print(users[i]); parsedUsers.add( Contact( userId: Int64(users.cast()[i][columnUserId]), @@ -79,6 +84,26 @@ class DbContacts extends CvModelBase { } } + static Future blockUser(int userId) async { + Map valuesToUpdate = { + columnBlocked: 1, + }; + await dbProvider.db!.update( + tableName, + valuesToUpdate, + where: "$columnUserId = ?", + whereArgs: [userId], + ); + } + + static Future deleteUser(int userId) async { + await dbProvider.db!.delete( + tableName, + where: "$columnUserId = ?", + whereArgs: [userId], + ); + } + static Future insertNewContact( String username, int userId, bool requested) async { try { diff --git a/lib/src/model/json/message.dart b/lib/src/model/json/message.dart index 77736c6..70ea9a2 100644 --- a/lib/src/model/json/message.dart +++ b/lib/src/model/json/message.dart @@ -3,7 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:twonly/src/utils/json.dart'; part 'message.g.dart'; -enum MessageKind { textMessage, image, video, contactRequest } +enum MessageKind { textMessage, image, video, contactRequest, rejectRequest } // so _$MessageKindEnumMap gets generated @JsonSerializable() diff --git a/lib/src/model/json/message.g.dart b/lib/src/model/json/message.g.dart index 5c8b056..c46c6b3 100644 --- a/lib/src/model/json/message.g.dart +++ b/lib/src/model/json/message.g.dart @@ -19,6 +19,7 @@ const _$MessageKindEnumMap = { MessageKind.image: 'image', MessageKind.video: 'video', MessageKind.contactRequest: 'contactRequest', + MessageKind.rejectRequest: 'rejectRequest', }; Message _$MessageFromJson(Map json) => Message( diff --git a/lib/src/providers/api_provider.dart b/lib/src/providers/api_provider.dart index 3f46fd4..cf147d7 100644 --- a/lib/src/providers/api_provider.dart +++ b/lib/src/providers/api_provider.dart @@ -42,6 +42,7 @@ class ApiProvider { bool _tryingToConnect = false; final log = Logger("api_provider"); Function(bool)? _connectionStateCallback; + Function? _updatedContacts; final HashMap messagesV0 = HashMap(); @@ -109,6 +110,10 @@ class ApiProvider { _connectionStateCallback = callBack; } + void setUpdatedContacts(Function callBack) { + _updatedContacts = callBack; + } + void tryToReconnect() { if (_tryingToConnect) return; _tryingToConnect = true; @@ -157,13 +162,22 @@ class ApiProvider { Int64 fromUserId = msg.v0.newMessage.fromUserId; Message? message = await SignalHelper.getDecryptedText(fromUserId, body); if (message != null) { - Result username = await getUsername(fromUserId); - if (username.isSuccess) { - print(username.value); - Uint8List name = username.value.userdata.username; - DbContacts.insertNewContact( - utf8.decode(name), fromUserId.toInt(), true); - print(message); + switch (message.kind) { + case MessageKind.contactRequest: + Result username = await getUsername(fromUserId); + if (username.isSuccess) { + Uint8List name = username.value.userdata.username; + DbContacts.insertNewContact( + utf8.decode(name), fromUserId.toInt(), true); + updateNotifier(); + } + break; + case MessageKind.rejectRequest: + DbContacts.deleteUser(fromUserId.toInt()); + updateNotifier(); + break; + default: + log.shout("Got unknown MessageKind $message"); } } var ok = client.Response_Ok()..none = true; @@ -182,6 +196,12 @@ class ApiProvider { _channel!.sink.add(resBytes); } + Future updateNotifier() async { + if (_updatedContacts != null) { + _updatedContacts!(); + } + } + Future _waitForResponse(Int64 seq) async { final startTime = DateTime.now(); diff --git a/lib/src/providers/notify_provider.dart b/lib/src/providers/notify_provider.dart new file mode 100644 index 0000000..bbeb39f --- /dev/null +++ b/lib/src/providers/notify_provider.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:twonly/src/model/contacts_model.dart'; + +/// Mix-in [DiagnosticableTreeMixin] to have access to [debugFillProperties] for the devtool +// ignore: prefer_mixin +class NotifyProvider with ChangeNotifier, DiagnosticableTreeMixin { + int _newContactRequests = 0; + List _allContacts = []; + + int get newContactRequests => _newContactRequests; + List get allContacts => _allContacts; + + void update() async { + _allContacts = await DbContacts.getUsers(); + + _newContactRequests = _allContacts + .where((contact) => !contact.accepted && contact.requested) + .length; + print(_newContactRequests); + notifyListeners(); + } + + /// Makes `Counter` readable inside the devtools by listing all of its properties + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('count', newContactRequests)); + } +} diff --git a/lib/src/signal/connect_pre_key_store.dart b/lib/src/signal/connect_pre_key_store.dart index d0611fe..baf26be 100644 --- a/lib/src/signal/connect_pre_key_store.dart +++ b/lib/src/signal/connect_pre_key_store.dart @@ -38,7 +38,6 @@ class ConnectPreKeyStore extends PreKeyStore { @override Future storePreKey(int preKeyId, PreKeyRecord record) async { if (!await containsPreKey(preKeyId)) { - print(preKeyId); await dbProvider.db!.insert(DB.tableName, {DB.columnPreKeyId: preKeyId, DB.columnPreKey: record.serialize()}); } else { diff --git a/lib/src/utils/api.dart b/lib/src/utils/api.dart index 595fc6c..11ac634 100644 --- a/lib/src/utils/api.dart +++ b/lib/src/utils/api.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:twonly/main.dart'; @@ -44,6 +45,25 @@ Future addNewContact(String username) async { return res.isSuccess; } +Future encryptAndSendMessage(Int64 userId, Message msg) async { + Uint8List? bytes = await SignalHelper.encryptMessage(msg, userId); + + if (bytes == null) { + Logger("utils/api").shout("Error encryption message!"); + return Result.error(ErrorCode.InternalError); + } + + Result resp = await apiProvider.sendTextMessage(userId, bytes); + + return resp; +} + +Future rejectUserRequest(Int64 userId) async { + Message msg = + Message(kind: MessageKind.rejectRequest, timestamp: DateTime.now()); + return encryptAndSendMessage(userId, msg); +} + Future createNewUser(String username, String inviteCode) async { final storage = getSecureStorage(); diff --git a/lib/src/utils/signal.dart b/lib/src/utils/signal.dart index 65c36cf..b883b56 100644 --- a/lib/src/utils/signal.dart +++ b/lib/src/utils/signal.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:ffi'; import 'dart:io'; import 'dart:typed_data'; import 'package:fixnum/fixnum.dart'; diff --git a/lib/src/views/chat_list_view.dart b/lib/src/views/chat_list_view.dart index 05ee2c1..0540481 100644 --- a/lib/src/views/chat_list_view.dart +++ b/lib/src/views/chat_list_view.dart @@ -1,5 +1,8 @@ -import 'package:twonly/src/components/initialsavatar_component.dart'; -import 'package:twonly/src/model/contacts_model.dart'; +import 'package:provider/provider.dart'; +import 'package:twonly/src/components/initialsavatar.dart'; +import 'package:twonly/src/components/message_send_state_icon.dart'; +import 'package:twonly/src/components/notification_badge.dart'; +import 'package:twonly/src/providers/notify_provider.dart'; import 'package:twonly/src/views/search_username_view.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'new_message_view.dart'; @@ -7,8 +10,6 @@ import 'package:flutter/material.dart'; import 'chat_item_details_view.dart'; import 'dart:async'; -enum MessageSendState { sending, send, opened, received } - class ChatItem { const ChatItem( {required this.username, @@ -57,19 +58,12 @@ class ChatListView extends StatefulWidget { class _ChatListViewState extends State { int _secondsSinceOpen = 0; - int _newContactRequests = 0; late Timer _timer; @override void initState() { super.initState(); _startTimer(); - _checkNewContactRequests(); - } - - Future _checkNewContactRequests() async { - _newContactRequests = (await DbContacts.getUsers()).length; - setState(() {}); } void _startTimer() { @@ -101,57 +95,12 @@ class _ChatListViewState extends State { } } - Widget getMessageSateIcon(MessageSendState state) { - List children = []; - Widget icon = Placeholder(); - String text = ""; - - switch (state) { - case MessageSendState.opened: - icon = Icon( - Icons.crop_square, - size: 14, - color: Theme.of(context).colorScheme.primary, - ); - text = "Opened"; - break; - case MessageSendState.received: - icon = Icon(Icons.square_rounded, - size: 14, color: Theme.of(context).colorScheme.primary); - text = "Received"; - break; - case MessageSendState.send: - icon = Icon(Icons.send, size: 14); - text = "Send"; - break; - case MessageSendState.sending: - icon = Row(children: [ - SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator( - strokeWidth: 1, - )), - SizedBox(width: 2) - ]); - text = "Sending"; - break; - } - children.add(const SizedBox(width: 5)); - return Row( - children: [ - icon, - const SizedBox(width: 3), - Text(text, style: TextStyle(fontSize: 12)), - const SizedBox(width: 5) - ], - ); - } - Widget getSubtitle(ChatItem item) { return Row( children: [ - getMessageSateIcon(item.state), + MessageSendStateIcon( + state: item.state, + ), Text("•"), const SizedBox(width: 5), Text(formatDuration(item.lastMessageInSeconds + _secondsSinceOpen), @@ -180,62 +129,42 @@ class _ChatListViewState extends State { appBar: AppBar( title: Text(AppLocalizations.of(context)!.chatsTitle), actions: [ - Stack( - children: [ - IconButton( - icon: Icon(Icons.person_add), // User with add icon - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SearchUsernameView(), - ), - ); - }, - ), - if (_newContactRequests > 0) - Positioned( - right: 5, - top: 0, - child: Container( - padding: EdgeInsets.all(5.0), // Add some padding - decoration: BoxDecoration( - color: Colors.red, // Background color - shape: BoxShape.circle, // Make it circular - ), - child: Center( - child: Text( - _newContactRequests.toString(), - style: TextStyle( - color: Colors.white, // Text color - fontSize: 10), - ), - ), + NotificationBadge( + count: context.watch().newContactRequests, + child: IconButton( + icon: Icon(Icons.person_add), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SearchUsernameView(), ), - ), - ], + ); + }, + ), ) ], ), body: ListView.builder( - restorationId: 'sampleItemListView', + restorationId: 'chat_list_view', itemCount: widget.items.length, itemBuilder: (BuildContext context, int index) { final item = widget.items[index]; return ListTile( - title: Text(item.username), - subtitle: getSubtitle(item), - leading: InitialsAvatar(displayName: item.username), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SampleItemDetailsView( - userId: item.userId, - ), + title: Text(item.username), + subtitle: getSubtitle(item), + leading: InitialsAvatar(displayName: item.username), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SampleItemDetailsView( + userId: item.userId, ), - ); - }); + ), + ); + }, + ); }, ), floatingActionButton: FloatingActionButton( diff --git a/lib/src/views/new_message_view.dart b/lib/src/views/new_message_view.dart index 063d2eb..cf00ba1 100644 --- a/lib/src/views/new_message_view.dart +++ b/lib/src/views/new_message_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:twonly/src/components/initialsavatar_component.dart'; +import 'package:twonly/src/components/initialsavatar.dart'; import 'package:twonly/src/model/contacts_model.dart'; import 'package:twonly/src/views/search_username_view.dart'; diff --git a/lib/src/views/search_username_view.dart b/lib/src/views/search_username_view.dart index de67443..14b972c 100644 --- a/lib/src/views/search_username_view.dart +++ b/lib/src/views/search_username_view.dart @@ -2,7 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; +import 'package:twonly/src/components/initialsavatar.dart'; import 'package:twonly/src/model/contacts_model.dart'; +import 'package:twonly/src/providers/notify_provider.dart'; import 'package:twonly/src/utils/api.dart'; import 'package:twonly/src/views/register_view.dart'; @@ -28,11 +31,9 @@ class _SearchUsernameView extends State { _isLoading = false; }); - Logger("search_user_name").warning("Replace instead of pop"); - if (context.mounted) { if (status) { - // Navigator.pop(context); + context.read().update(); } else if (context.mounted) { showAlertDialog( context, @@ -88,17 +89,19 @@ class _SearchUsernameView extends State { showAlertDialog(context, "Coming soon", "This feature is not yet implemented!"); }, - label: Text("QR-Code scannen"), + label: + Text(AppLocalizations.of(context)!.searchUsernameQrCodeBtn), ), SizedBox(height: 30), - Container( - alignment: Alignment.centerLeft, - padding: EdgeInsets.symmetric(horizontal: 4.0, vertical: 10), - child: Text( - "Neue Followanfragen", - style: TextStyle(fontSize: 20), + if (context.read().allContacts.isNotEmpty) + Container( + alignment: Alignment.centerLeft, + padding: EdgeInsets.symmetric(horizontal: 4.0, vertical: 10), + child: Text( + AppLocalizations.of(context)!.searchUsernameNewFollowerTitle, + style: TextStyle(fontSize: 20), + ), ), - ), Expanded( child: ContactsListView(), ) @@ -126,53 +129,55 @@ class ContactsListView extends StatefulWidget { } class _ContactsListViewState extends State { - List _allContacts = []; - - @override - void initState() { - super.initState(); - _loadContacts(); - } - - Future _loadContacts() async { - List allContacts = await DbContacts.getUsers(); - _allContacts = allContacts.where((contact) => !contact.accepted).toList(); - } - @override Widget build(BuildContext context) { + List contacts = context.read().allContacts; return ListView.builder( - itemCount: _allContacts.length, + itemCount: contacts.length, itemBuilder: (context, index) { - final contact = _allContacts[index]; - - if (!contact.requested) { - return ListTile( - title: Text(contact.displayName), - subtitle: Text('Pending'), - ); - } - + final contact = contacts[index]; return ListTile( title: Text(contact.displayName), + leading: InitialsAvatar(displayName: contact.displayName), trailing: Row( mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(Icons.close, color: Colors.red), - onPressed: () { - // Handle reject action - print('Rejected ${contact.displayName}'); - }, - ), - IconButton( - icon: Icon(Icons.check, color: Colors.green), - onPressed: () { - // Handle accept action - print('Accepted ${contact.displayName}'); - }, - ), - ], + children: (!contact.requested) + ? [Text('Pending')] + : [ + Tooltip( + message: "Block the user without informing.", + child: IconButton( + icon: Icon(Icons.person_off_rounded, + color: const Color.fromARGB(164, 244, 67, 54)), + onPressed: () async { + await DbContacts.blockUser(contact.userId.toInt()); + if (context.mounted) { + context.read().update(); + } + }, + ), + ), + Tooltip( + message: "Reject the request and let the requester know.", + child: IconButton( + icon: Icon(Icons.close, color: Colors.red), + onPressed: () async { + await DbContacts.deleteUser(contact.userId.toInt()); + if (context.mounted) { + context.read().update(); + } + rejectUserRequest(contact.userId); + }, + ), + ), + IconButton( + icon: Icon(Icons.check, color: Colors.green), + onPressed: () { + // Handle accept action + print('Accepted ${contact.displayName}'); + }, + ), + ], ), ); }, diff --git a/lib/src/views/share_image_view.dart b/lib/src/views/share_image_view.dart index 90a111c..d482feb 100644 --- a/lib/src/views/share_image_view.dart +++ b/lib/src/views/share_image_view.dart @@ -3,7 +3,7 @@ import 'dart:collection'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:twonly/src/components/initialsavatar_component.dart'; +import 'package:twonly/src/components/initialsavatar.dart'; import 'package:twonly/src/model/contacts_model.dart'; class ShareImageView extends StatefulWidget {