diff --git a/lib/main.dart b/lib/main.dart index 9921c17..0ffe856 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,8 @@ import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; +import 'package:twonly/src/providers/api/api.dart'; import 'package:twonly/src/providers/api_provider.dart'; import 'package:twonly/src/providers/db_provider.dart'; import 'package:flutter/material.dart'; @@ -17,7 +20,7 @@ late ApiProvider apiProvider; void main() async { final settingsController = SettingsController(SettingsService()); - // Load the user's peganreferred theme while the splash screen is displayed. + // Load the user's preferred theme while the splash screen is displayed. // This prevents a sudden theme change when the app is first displayed. await settingsController.loadSettings(); @@ -33,6 +36,11 @@ void main() async { } }); + final dir = await getApplicationDocumentsDirectory(); + Hive.init(dir.path); + + await initMediaStorage(); + dbProvider = DbProvider(); // Database is just a file, so this will not block the loading of the app much await dbProvider.ready; diff --git a/lib/src/components/initialsavatar.dart b/lib/src/components/initialsavatar.dart index b8d8483..8179d70 100644 --- a/lib/src/components/initialsavatar.dart +++ b/lib/src/components/initialsavatar.dart @@ -25,18 +25,31 @@ class InitialsAvatar extends StatelessWidget { Color avatarColor = _getColorFromUsername( displayName, Theme.of(context).brightness == Brightness.dark); - return CircleAvatar( - backgroundColor: avatarColor, - radius: fontSize, - child: Text( - initials, - style: TextStyle( - color: _getTextColor(avatarColor), - fontWeight: FontWeight.normal, - fontSize: fontSize, - ), + Widget child = Text( + initials, + style: TextStyle( + color: _getTextColor(avatarColor), + fontWeight: FontWeight.normal, + fontSize: fontSize, ), ); + + bool isPro = initials[0] == "T"; + + double proSize = (fontSize == null) ? 40 : (fontSize! * 2); + + return isPro + ? ClipRRect( + borderRadius: BorderRadius.circular(12.0), //or 15.0 + child: Container( + height: proSize, + width: proSize, + color: avatarColor, + child: Center(child: child), + ), + ) + : CircleAvatar( + backgroundColor: avatarColor, radius: fontSize, child: child); } Color _getTextColor(Color color) { diff --git a/lib/src/components/message_send_state_icon.dart b/lib/src/components/message_send_state_icon.dart index 42583aa..38a5064 100644 --- a/lib/src/components/message_send_state_icon.dart +++ b/lib/src/components/message_send_state_icon.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/model/json/message.dart'; enum MessageSendState { received, @@ -11,37 +13,39 @@ enum MessageSendState { class MessageSendStateIcon extends StatelessWidget { final MessageSendState state; + final MessageKind kind; + final bool isDownloaded; - const MessageSendStateIcon(this.state, {super.key}); + const MessageSendStateIcon(this.state, this.isDownloaded, this.kind, + {super.key}); @override Widget build(BuildContext context) { Widget icon = Placeholder(); String text = ""; + Color color = Theme.of(context).colorScheme.primary; + if (kind == MessageKind.textMessage) { + color = Colors.lightBlue; + } else if (kind == MessageKind.video) { + color = Colors.deepPurple; + } + switch (state) { case MessageSendState.receivedOpened: + icon = Icon(Icons.crop_square, size: 14, color: color); + text = "Received"; + break; case MessageSendState.sendOpened: - icon = Icon( - Icons.crop_square, - size: 14, - color: Theme.of(context).colorScheme.primary, - ); + icon = FaIcon(FontAwesomeIcons.paperPlane, size: 12, color: color); text = "Opened"; break; case MessageSendState.received: - icon = Icon( - Icons.square_rounded, - size: 14, - color: Theme.of(context).colorScheme.primary, - ); + icon = Icon(Icons.square_rounded, size: 14, color: color); text = "Received"; break; case MessageSendState.send: - icon = Icon( - Icons.send, - size: 14, - ); + icon = FaIcon(FontAwesomeIcons.solidPaperPlane, size: 12, color: color); text = "Send"; break; case MessageSendState.sending: @@ -51,9 +55,7 @@ class MessageSendStateIcon extends StatelessWidget { SizedBox( width: 10, height: 10, - child: CircularProgressIndicator( - strokeWidth: 1, - ), + child: CircularProgressIndicator(strokeWidth: 1, color: color), ), SizedBox(width: 2), ], @@ -62,6 +64,10 @@ class MessageSendStateIcon extends StatelessWidget { break; } + if (!isDownloaded) { + text = "Tap do load"; + } + return Row( children: [ icon, diff --git a/lib/src/components/user_context_menu.dart b/lib/src/components/user_context_menu.dart index c687a02..0523b39 100644 --- a/lib/src/components/user_context_menu.dart +++ b/lib/src/components/user_context_menu.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:pie_menu/pie_menu.dart'; import 'package:twonly/src/model/contacts_model.dart'; @@ -22,7 +23,6 @@ class _UserContextMenuState extends State { tooltip: const Text('Verify user'), onSelect: () { print('Verify user selected'); - // Add your verification logic here }, child: const Icon(Icons.gpp_maybe_rounded), // Can be any widget ), @@ -30,9 +30,8 @@ class _UserContextMenuState extends State { tooltip: const Text('Send image'), onSelect: () { print('Send image selected'); - // Add your image sending logic here }, - child: const Icon(Icons.camera_alt_rounded), // Can be any widget + child: const FaIcon(FontAwesomeIcons.camera), ), ], child: widget.child, diff --git a/lib/src/model/messages_model.dart b/lib/src/model/messages_model.dart index eef7ebc..8e5e2ac 100644 --- a/lib/src/model/messages_model.dart +++ b/lib/src/model/messages_model.dart @@ -11,7 +11,7 @@ class DbMessage { required this.messageId, required this.messageOtherId, required this.otherUserId, - required this.messageMessageKind, + required this.messageKind, required this.messageContent, required this.messageOpenedAt, required this.messageAcknowledgeByUser, @@ -23,12 +23,17 @@ class DbMessage { // is this null then the message was sent from the user itself int? messageOtherId; int otherUserId; - MessageKind messageMessageKind; + MessageKind messageKind; MessageContent? messageContent; DateTime? messageOpenedAt; bool messageAcknowledgeByUser; bool messageAcknowledgeByServer; DateTime sendOrReceivedAt; + + bool containsOtherMedia() { + if (messageOtherId == null) return false; + return messageKind == MessageKind.image || messageKind == MessageKind.video; + } } class DbMessages extends CvModelBase { @@ -44,7 +49,7 @@ class DbMessages extends CvModelBase { final otherUserId = CvField(columnOtherUserId); static const columnMessageKind = "message_kind"; - final messageMessageKind = CvField(columnMessageKind); + final messageKind = CvField(columnMessageKind); static const columnMessageContentJson = "message_json"; final messageContentJson = CvField(columnMessageContentJson); @@ -135,7 +140,8 @@ class DbMessages extends CvModelBase { columnMessageAcknowledgeByServer: 1, columnMessageAcknowledgeByUser: 0, // ack in case of sending corresponds to the opened flag - columnOtherUserId: userIdFrom + columnOtherUserId: userIdFrom, + columnSendOrReceivedAt: DateTime.now().toIso8601String() }); globalCallBackOnMessageChange(userIdFrom); return true; @@ -204,7 +210,7 @@ class DbMessages extends CvModelBase { @override List get fields => [ messageId, - messageMessageKind, + messageKind, messageContentJson, messageOpenedAt, sendOrReceivedAt @@ -230,7 +236,7 @@ class DbMessages extends CvModelBase { messageId: fromDb[i][columnMessageId], messageOtherId: fromDb[i][columnMessageOtherId], otherUserId: fromDb[i][columnOtherUserId], - messageMessageKind: + messageKind: MessageKindExtension.fromIndex(fromDb[i][columnMessageKind]), messageContent: content, messageOpenedAt: messageOpenedAt, diff --git a/lib/src/providers/api/api.dart b/lib/src/providers/api/api.dart index 3e2bf94..5bc2ac6 100644 --- a/lib/src/providers/api/api.dart +++ b/lib/src/providers/api/api.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'dart:io'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; import 'package:logging/logging.dart'; import 'package:twonly/main.dart'; import 'package:twonly/src/model/json/message.dart'; @@ -104,4 +106,42 @@ Future tryDownloadMedia(List imageToken, {bool force = false}) async { print("check if free network connection"); print("Downloading: " + imageToken.toString()); + + final box = await getMediaStorage(); + + Uint8List imageBytes = Uint8List.fromList([0]); + + box.put(imageToken.toString(), imageBytes); +} + +Future isMediaDownloaded(List mediaToken) async { + final box = await getMediaStorage(); + + // box.put('secret', 'Hive is awesome'); + + return box.containsKey(mediaToken.toString()); +} + +Future initMediaStorage() async { + final storage = getSecureStorage(); + var containsEncryptionKey = + await storage.containsKey(key: 'hive_encryption_key'); + if (!containsEncryptionKey) { + var key = Hive.generateSecureKey(); + await storage.write( + key: 'hive_encryption_key', + value: base64UrlEncode(key), + ); + } +} + +Future getMediaStorage() async { + await initMediaStorage(); + + final storage = getSecureStorage(); + var encryptionKey = + base64Url.decode((await storage.read(key: 'hive_encryption_key'))!); + + return await Hive.openBox('media_storage', + encryptionCipher: HiveAesCipher(encryptionKey)); } diff --git a/lib/src/providers/api_provider.dart b/lib/src/providers/api_provider.dart index b7d61f3..f65aad6 100644 --- a/lib/src/providers/api_provider.dart +++ b/lib/src/providers/api_provider.dart @@ -152,7 +152,9 @@ class ApiProvider { Future _sendRequestV0(ClientToServer request) async { if (_channel == null) { - return Result.error(ErrorCode.InternalError); + if (!await connect()) { + return Result.error(ErrorCode.InternalError); + } } var seq = Int64(Random().nextInt(4294967296)); while (messagesV0.containsKey(seq)) { diff --git a/lib/src/views/chat_item_details_view.dart b/lib/src/views/chat_item_details_view.dart index ee20120..1faa81d 100644 --- a/lib/src/views/chat_item_details_view.dart +++ b/lib/src/views/chat_item_details_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/src/model/contacts_model.dart'; class AlignedTextBox extends StatelessWidget { @@ -39,8 +40,8 @@ class AlignedTextBox extends StatelessWidget { } /// Displays detailed information about a SampleItem. -class SampleItemDetailsView extends StatelessWidget { - const SampleItemDetailsView({super.key, required this.user}); +class ChatItemDetailsView extends StatelessWidget { + const ChatItemDetailsView({super.key, required this.user}); final Contact user; @@ -86,24 +87,50 @@ class SampleItemDetailsView extends StatelessWidget { }, ), ), - const SizedBox( - height: 10, - ), Padding( - padding: const EdgeInsets.only(bottom: 40, left: 10, right: 10), - child: TextField( - decoration: InputDecoration( - // border: OutlineInputBorder(), - labelText: 'Enter your message', - suffixIcon: IconButton( - icon: Icon(Icons.send), + padding: + const EdgeInsets.only(bottom: 30, left: 20, right: 20, top: 10), + child: Row( + children: [ + Expanded( + child: TextField( + // controller: _controller, + decoration: InputDecoration( + hintText: 'Type a message', + contentPadding: EdgeInsets.symmetric(horizontal: 10) + // border: OutlineInputBorder(), + ), + ), + ), + SizedBox(width: 8), + IconButton( + icon: FaIcon(FontAwesomeIcons.solidPaperPlane), onPressed: () { // Handle send action }, ), - ), + ], ), ), + // Container( + // child: Row(children: [ + // Padding( + // padding: const EdgeInsets.only(bottom: 40, left: 10, right: 10), + // child: TextField( + // decoration: InputDecoration( + // // border: OutlineInputBorder(), + // hintText: 'Enter your message', + // ), + // ), + // ), + // IconButton( + // icon: FaIcon(FontAwesomeIcons.solidPaperPlane), + // onPressed: () { + // // Handle send action + // }, + // ), + // ]), + // ), ], ), ); diff --git a/lib/src/views/chat_list_view.dart b/lib/src/views/chat_list_view.dart index 4026161..5040aad 100644 --- a/lib/src/views/chat_list_view.dart +++ b/lib/src/views/chat_list_view.dart @@ -5,11 +5,13 @@ import 'package:twonly/src/components/notification_badge.dart'; import 'package:twonly/src/components/user_context_menu.dart'; import 'package:twonly/src/model/contacts_model.dart'; import 'package:twonly/src/model/messages_model.dart'; +import 'package:twonly/src/providers/api/api.dart'; import 'package:twonly/src/providers/contacts_change_provider.dart'; import 'package:twonly/src/providers/messages_change_provider.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/chat_item_details_view.dart'; import 'package:twonly/src/views/home_view.dart'; +import 'package:twonly/src/views/media_viewer_view.dart'; import 'package:twonly/src/views/search_username_view.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter/material.dart'; @@ -47,6 +49,11 @@ class _ChatListViewState extends State { List activeUsers = allUsers .where((x) => lastMessages.containsKey(x.userId.toInt())) .toList(); + activeUsers.sort((b, a) { + return lastMessages[a.userId.toInt()]! + .sendOrReceivedAt + .compareTo(lastMessages[b.userId.toInt()]!.sendOrReceivedAt); + }); return Scaffold( appBar: AppBar( @@ -125,24 +132,28 @@ class UserListItem extends StatefulWidget { class _UserListItem extends State { int flames = 0; int lastMessageInSeconds = 0; + bool isDownloaded = true; @override void initState() { super.initState(); - //_loadAsync(); + _loadAsync(); } - // Future _loadAsync() async { - // flames = await widget.user.getFlames(); - // lastMessageInSeconds = await widget.user.getLastMessageInSeconds(); - // setState(() {}); - // } + Future _loadAsync() async { + // flames = await widget.user.getFlames(); + // setState(() {}); + + if (widget.lastMessage.containsOtherMedia()) { + isDownloaded = await isMediaDownloaded( + widget.lastMessage.messageContent!.downloadToken!); + setState(() {}); + } + } @override Widget build(BuildContext context) { MessageSendState state; - // int lastMessageInSeconds = widget.lastMessage.sendOrReceivedAt; - //print(widget.lastMessage.sendOrReceivedAt); int lastMessageInSeconds = DateTime.now() .difference(widget.lastMessage.sendOrReceivedAt) .inSeconds; @@ -173,7 +184,8 @@ class _UserListItem extends State { title: Text(widget.user.displayName), subtitle: Row( children: [ - MessageSendStateIcon(state), + MessageSendStateIcon( + state, isDownloaded, widget.lastMessage.messageKind), Text("•"), const SizedBox(width: 5), Text( @@ -203,11 +215,19 @@ class _UserListItem extends State { onTap: () { Navigator.push( context, - MaterialPageRoute( - builder: (context) => SampleItemDetailsView( - user: widget.user, - ), - ), + MaterialPageRoute(builder: (context) { + if (state == MessageSendState.received && + widget.lastMessage.containsOtherMedia()) { + List token = + widget.lastMessage.messageContent!.downloadToken!; + if (isDownloaded) { + return MediaViewerView(widget.user); + } else { + tryDownloadMedia(token); + } + } + return ChatItemDetailsView(user: widget.user); + }), ); }, ), diff --git a/lib/src/views/home_view.dart b/lib/src/views/home_view.dart index 70d5053..b0edd13 100644 --- a/lib/src/views/home_view.dart +++ b/lib/src/views/home_view.dart @@ -1,3 +1,4 @@ +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:pie_menu/pie_menu.dart'; import 'camera_preview_view.dart'; import 'chat_list_view.dart'; @@ -79,12 +80,14 @@ class HomeViewState extends State { selectedIconTheme: IconThemeData(color: const Color.fromARGB(255, 255, 255, 255)), items: [ - BottomNavigationBarItem(icon: Icon(Icons.chat), label: ""), BottomNavigationBarItem( - icon: Icon(Icons.camera_alt), + icon: FaIcon(FontAwesomeIcons.solidComments), label: ""), + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.camera), label: "", ), - BottomNavigationBarItem(icon: Icon(Icons.verified_user), label: ""), + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.userShield), label: ""), ], onTap: (int index) { activePageIdx = index; diff --git a/lib/src/views/media_viewer_view.dart b/lib/src/views/media_viewer_view.dart new file mode 100644 index 0000000..fcfe653 --- /dev/null +++ b/lib/src/views/media_viewer_view.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/model/contacts_model.dart'; + +class MediaViewerView extends StatefulWidget { + final Contact otherUser; + const MediaViewerView(this.otherUser, {super.key}); + + @override + State createState() => _MediaViewerViewState(); +} + +class _MediaViewerViewState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Text(widget.otherUser.displayName), + ); + } +} diff --git a/lib/src/views/share_image_editor_view.dart b/lib/src/views/share_image_editor_view.dart index 8cdaf5e..ece06e0 100644 --- a/lib/src/views/share_image_editor_view.dart +++ b/lib/src/views/share_image_editor_view.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/share_image_view.dart'; @@ -63,10 +64,7 @@ class _ShareImageEditorView extends State { // mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( - icon: Icon( - Icons.close, - size: 30, - ), + icon: Icon(Icons.close, size: 30), color: Colors.white, onPressed: () async { Navigator.pop(context); @@ -87,7 +85,7 @@ class _ShareImageEditorView extends State { OutlinedButton.icon( icon: _imageSaved ? Icon(Icons.check) - : Icon(Icons.save_rounded), + : FaIcon(FontAwesomeIcons.floppyDisk), style: OutlinedButton.styleFrom( iconColor: _imageSaved ? Theme.of(context).colorScheme.outline @@ -113,7 +111,7 @@ class _ShareImageEditorView extends State { ), const SizedBox(width: 20), FilledButton.icon( - icon: Icon(Icons.send), + icon: FaIcon(FontAwesomeIcons.solidPaperPlane), onPressed: () async { Navigator.push( context, diff --git a/lib/src/views/share_image_view.dart b/lib/src/views/share_image_view.dart index 48bcffe..35874ef 100644 --- a/lib/src/views/share_image_view.dart +++ b/lib/src/views/share_image_view.dart @@ -2,6 +2,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:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/src/components/best_friends_selector.dart'; import 'package:twonly/src/components/headline.dart'; import 'package:twonly/src/components/initialsavatar.dart'; @@ -107,7 +108,7 @@ class _ShareImageView extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ FilledButton.icon( - icon: Icon(Icons.send), + icon: FaIcon(FontAwesomeIcons.solidPaperPlane), onPressed: () async { sendImage(_selectedUserIds.toList(), widget.image); diff --git a/pubspec.lock b/pubspec.lock index cbd1fae..c7d7add 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -490,6 +490,14 @@ packages: description: flutter source: sdk version: "0.0.0" + font_awesome_flutter: + dependency: "direct main" + description: + name: font_awesome_flutter + sha256: d3a89184101baec7f4600d58840a764d2ef760fe1c5a20ef9e6b0e9b24a07a3a + url: "https://pub.dev" + source: hosted + version: "10.8.0" frontend_server_client: dependency: transitive description: @@ -530,6 +538,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" http: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a0cb68c..0ae2592 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,8 +20,10 @@ dependencies: flutter_localizations: sdk: flutter flutter_secure_storage: ^9.2.2 + font_awesome_flutter: ^10.8.0 gal: ^2.3.1 google_fonts: ^6.2.1 + hive: ^2.2.3 image: ^4.3.0 intl: any introduction_screen: ^3.1.14