diff --git a/lib/_shared/button/button_with_popup.dart b/lib/_shared/button/button_with_popup.dart new file mode 100644 index 0000000..eebc004 --- /dev/null +++ b/lib/_shared/button/button_with_popup.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +class ButtonWithPopup extends StatefulWidget { + const ButtonWithPopup( + { + // required this.onChanged, + required this.items, + this.onTap, + required this.child}); + // final Function(T) onChanged; + final List> items; + final Widget child; + final Function()? onTap; + @override + _ButtonWithPopupState createState() => _ButtonWithPopupState(); +} + +class _ButtonWithPopupState extends State> { + String? selectedItem; + final LayerLink _layerLink = LayerLink(); + + void _onDropdownTap() async { + print('onDropdownTap'); + // Khi đã có dữ liệu, hiển thị dropdown items + if (widget.items.isNotEmpty) { + print('items không rỗng'); + _showOverlay(); //ko do day + } else { + print('items rỗng'); + // MsgDialog.showError(msg: 'Không tải được dữ liệu!'); + } + } + + final GlobalKey _key = GlobalKey(); + OverlayEntry? _overlayEntry; + void _showOverlay() { + print('show overlay'); +//null check + final renderBox = _key.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + print('size (dropdownNoFetchItems): $size'); + final position = renderBox.localToGlobal(Offset.zero); + + // Tính chiều cao của popup dựa trên số lượng items + double popupHeight = widget.items.length * 50.0; // Giả sử mỗi item cao 58px + // final offset = renderBox.localToGlobal(Offset.zero); + // Lấy chiều cao màn hình + // Tính khoảng cách từ vị trí widget đến mép trên + double distanceToTop = position.dy; + + // Tính khoảng cách offset để giữ popup cách mép trên 100 pixels nếu cần thiết + double offsetY = -popupHeight; + if (distanceToTop - popupHeight < 20) { + //cach mep tren man hinh 20 + offsetY = -distanceToTop + 20; + } + _overlayEntry = OverlayEntry( + builder: (context) => Stack( + children: [ + GestureDetector( + onTap: () { + _removeOverlay(); // Call this when tapping outside + }, + child: Container( + color: Colors.transparent, // Transparent barrier + ), + ), + Positioned( + // left: offset.dx, + // top: offset.dy + size.height, + width: size.width, + // width: _key.currentContext!.size!.width, + child: CompositedTransformFollower( + //để popup luôn di chuyển theo field khi cuộn + link: _layerLink, + showWhenUnlinked: false, + offset: Offset(0, offsetY), + // offset: Offset(0, -popupHeight), + // offset: Offset(0, _key.currentContext!.size!.height), + child: Material( + elevation: 2.0, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: popupHeight, // Đặt chiều cao tối đa của popup + ), + child: ListView( + padding: EdgeInsets.zero, + shrinkWrap: true, + children: widget.items.map((item) { + return SizedBox( + height: 50, + child: ListTile( + title: item.child, + onTap: () { + item.onTap?.call(); + // setState(() { + // selectedItem = (item.child as Text).data; + // }); + // if (item.value != null) { + // widget.onChanged(item.value!); + // } + _removeOverlay(); + }, + ), + ); + }).toList(), + ), + ), + ), + ), + ), + ], + ), + ); + + Overlay.of(context).insert(_overlayEntry!); + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + @override + Widget build(BuildContext context) { + // const lightgrey = const Color.fromRGBO(237, 237, 237, 1); + // const darkgrey = const Color.fromRGBO(104, 102, 102, 1); + return CompositedTransformTarget( + link: _layerLink, + child: GestureDetector( + key: _key, + onTap: widget.onTap, + onLongPress: _onDropdownTap, + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + if (_overlayEntry != null) { + _removeOverlay(); + } + }, // Điều khiển khi tap vào dropdown + child: Material( + child: widget.child, + )), + ); + } +} diff --git a/lib/_shared/button/my_chip_button.dart b/lib/_shared/button/my_chip_button.dart new file mode 100644 index 0000000..6c2f25f --- /dev/null +++ b/lib/_shared/button/my_chip_button.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class MyFilterChipButton extends StatelessWidget { + const MyFilterChipButton({ + super.key, + required this.onSelected, + required this.label, + required this.selected, + }); + final void Function(bool) onSelected; + final String label; + final bool selected; + @override + Widget build(BuildContext context) { + return FilterChip( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + label: Text(label), + selected: selected, //t//bloc.hasLyric && + // selected: bloc.isHighLightMode,//f + disabledColor: Colors.grey, + selectedColor: Colors.black, + onSelected: (bool value) { + onSelected(value); + }); + } +} diff --git a/lib/_shared/button/my_close_button.dart b/lib/_shared/button/my_close_button.dart new file mode 100644 index 0000000..a79d2ff --- /dev/null +++ b/lib/_shared/button/my_close_button.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class MyCloseButton extends StatelessWidget { + const MyCloseButton({super.key, required this.onPressed}); + final void Function() onPressed; + @override + Widget build(BuildContext context) { + return IconButton( + icon: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey, + ), + child: const Icon(Icons.close, size: 20)), //24 + padding: EdgeInsets.zero, // Không có padding + constraints: const BoxConstraints(), // Loại bỏ các ràng buộc mặc định + onPressed: onPressed, + ); + } +} diff --git a/lib/_shared/button/my_elevated_button.dart b/lib/_shared/button/my_elevated_button.dart new file mode 100644 index 0000000..c81004d --- /dev/null +++ b/lib/_shared/button/my_elevated_button.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +// ignore: must_be_immutable +class MyElevatedButton extends StatelessWidget { + const MyElevatedButton( + {super.key, + required this.onPressed, + required this.buttonName, + this.textColor, + this.backgroundColor, + this.width, + this.height}); + final String buttonName; + final Function()? onPressed; + final Color? textColor; + final Color? backgroundColor; + final double? width; + final double? height; + @override + Widget build(BuildContext context) { + return ElevatedButton( + style: ButtonStyle( + // fixedSize:MaterialStatePropertyAll(Size(width??,height)), + // textStyle: MaterialStatePropertyAll( + // GoogleFonts.sarabun( + // color: textColor, fontWeight: FontWeight.bold, fontSize: 12) + // ), + elevation: WidgetStateProperty.all(6), + backgroundColor: + WidgetStatePropertyAll(backgroundColor ?? Colors.transparent), + ), + onPressed: onPressed, + child: Text(buttonName)); + } +} diff --git a/lib/_shared/data/chat_data.dart b/lib/_shared/data/chat_data.dart index e60861d..3c928c4 100644 --- a/lib/_shared/data/chat_data.dart +++ b/lib/_shared/data/chat_data.dart @@ -21,6 +21,9 @@ final class Message { Map toMap() => {'message': message, 'sender': sender, 'time': timestamp}; + @override + String toString() => toMap().toString(); + // 'Message(id: $id, message: $message, sender: $sender, timestamp: $timestamp, translations: $translations)'; } final class UserDetail { diff --git a/lib/_shared/dialog/loading_dialog.dart b/lib/_shared/dialog/loading_dialog.dart new file mode 100644 index 0000000..0ee41ce --- /dev/null +++ b/lib/_shared/dialog/loading_dialog.dart @@ -0,0 +1,28 @@ +import 'package:flutter/cupertino.dart'; +import '../../config/routes/navigator.dart'; +import '../../main.dart'; + +class LoadingDialog { + static showLoading({BuildContext? context}) { + showCupertinoDialog( + context: globalAppContext ?? context!, + builder: (context) { + return const CupertinoAlertDialog( + content: LoadingState(), + ); + }); + } + + static hideLoading() { + pop(); + } +} + +class LoadingState extends StatelessWidget { + const LoadingState({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: CupertinoActivityIndicator()); + } +} diff --git a/lib/_shared/dialog/message_dialog.dart b/lib/_shared/dialog/message_dialog.dart new file mode 100644 index 0000000..ce73419 --- /dev/null +++ b/lib/_shared/dialog/message_dialog.dart @@ -0,0 +1,71 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import '../../config/routes/navigator.dart'; +import '../../main.dart'; + +class MessageDialog { + static void showMessageDialog({ + Widget? contentWidget, + String? contentText, + String? closeText = 'Close', + List? actions, + bool tapOutsideToClose = false, + Color? color, + Widget? titleWidget, + String? titleText, + Function()? onTapClose, + bool showCloseButton = true, + }) { + showCupertinoModalPopup( + context: globalAppContext!, + barrierDismissible: tapOutsideToClose, + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: titleWidget ?? + Text( + titleText ?? '', + style: const TextStyle( + color: Colors.blue, fontWeight: FontWeight.bold), + ), + content: Material( + color: Colors.transparent, + child: contentWidget ?? + Text(contentText ?? '', + textAlign: TextAlign.start, + style: const TextStyle(fontSize: 16)), + ), + actions: showCloseButton + ? actions ?? + [ + TextButton( + onPressed: onTapClose ?? + () { + pop(); + }, + child: Text(closeText ?? 'OK')) + ] + : [], + ); + }, + ); + } + + static void showError( + String err, { + Widget? contentWidget, + String? closeText = 'Close', + List? actions, + bool tapOutsideToClose = false, + String? titleText, + }) { + showMessageDialog( + color: Colors.red, + contentText: err, + titleWidget: Text(titleText ?? 'Error', + style: const TextStyle(color: Colors.red, fontSize: 20)), + contentWidget: contentWidget, + closeText: closeText, + actions: actions, + tapOutsideToClose: tapOutsideToClose); + } +} diff --git a/lib/_shared/widgets/chat_bubble_widget.dart b/lib/_shared/widgets/chat_bubble_widget.dart index 1882ced..c78dfb2 100644 --- a/lib/_shared/widgets/chat_bubble_widget.dart +++ b/lib/_shared/widgets/chat_bubble_widget.dart @@ -1,28 +1,39 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_network/image_network.dart'; +import '../../features/translate_settings/trans_bloc.dart'; +import '../../features/translate_settings/widgets/translate_settings_button.dart'; +import '../../service_locator/service_locator.dart'; +import '../../utils/send_to_gemini.dart'; +import '../button/button_with_popup.dart'; +import '../dialog/loading_dialog.dart'; +import 'translations_widget.dart'; +// ignore: must_be_immutable class ChatBubble extends StatelessWidget { final bool isMine; final String message; final String? photoUrl; final String? displayName; final Map translations; + final String id; - final double _iconSize = 24.0; - - const ChatBubble( + ChatBubble( {required this.isMine, required this.message, required this.photoUrl, required this.displayName, this.translations = const {}, - super.key}); + super.key, + required this.id}); + final double _iconSize = 24.0; @override Widget build(BuildContext context) { - final List widgets = []; - // user avatar + final List widgets = []; //cp at here widgets.add(Padding( padding: const EdgeInsets.all(8.0), child: ClipRRect( @@ -41,65 +52,96 @@ class ChatBubble extends StatelessWidget { )); // message bubble - widgets.add(Container( - constraints: - BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), - color: isMine ? Colors.black26 : Colors.black87), - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: - isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - // display name - Text( - displayName ?? 'Unknown', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: isMine ? Colors.black87 : Colors.grey, - fontWeight: FontWeight.bold), + final messageBubble = Container( + constraints: + BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + color: isMine ? Colors.black26 : Colors.black87), + padding: const EdgeInsets.all(8.0), + child: BlocBuilder(builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: + isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + // display name + Text( + displayName ?? 'Unknown', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isMine ? Colors.black87 : Colors.grey, + fontWeight: FontWeight.bold), + ), + // original language + Text( + message, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Colors.white), + ), + // english version (if there is) + if (state is ChangeLangState && !isMine) + FutureBuilder>( + future: getTranslations( + context, message, state.selectedLanguages), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Text('Đang dịch...'); + } + if (snapshot.hasData) { + print('snapshot.data: ${snapshot.data}'); + return TranslationsWidget( + translations: snapshot.data!, + widget: this, + ); + } + return const LoadingState(); + }, + ) + ], + ); + })); + widgets.add(ButtonWithPopup( + items: [ + DropdownMenuItem( + child: const Text('Copy'), + onTap: () { + Clipboard.setData(ClipboardData(text: message)); + }, ), - // original language - Text( - message, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: Colors.white), + DropdownMenuItem( + child: const Text('Dịch'), + onTap: () { + showSelectLang(); + }, ), - // english version (if there is) - if (translations.isNotEmpty) - ...translations.entries - .where( - (element) => element.key != 'original', - ) - .map( - (e) => Text.rich( - TextSpan(children: [ - TextSpan( - text: '${e.key} ', - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - fontWeight: FontWeight.bold, - color: - isMine ? Colors.black87 : Colors.grey)), - TextSpan( - text: e.value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontStyle: FontStyle.italic, - color: isMine ? Colors.black87 : Colors.grey), - ) - ]), - textAlign: isMine ? TextAlign.right : TextAlign.left, - ), - ) + DropdownMenuItem( + child: const Text('Tìm kiếm'), + onTap: () { +//TODO + }, + ), + DropdownMenuItem( + child: const Text('Hỏi Gemini'), + onTap: () { +//TODO + }, + ), + if (isMine) + DropdownMenuItem( + child: const Text('Xóa'), + onTap: () { +//TODO + }, + ), ], - ), - )); + onTap: () async { + //TODO: transalte by ONE TAP + }, + child: messageBubble)); + return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: Row( @@ -110,6 +152,83 @@ class ChatBubble extends StatelessWidget { ), ); } + + Map _resultTranslations = {}; + + List _previousLanguages = []; + + Future> getTranslations(BuildContext context, + String message, List selectedLanguages) async { + final _firebaseInstance = FirebaseFirestore.instance; + // final _firebaseInstance = ServiceLocator.get(); + //nếu có lang dịch rồi, lang chưa: vẫn lấy "translations" từ firestore vì lang dịch rồi đó sẽ có trên firebase + if (_previousLanguages.equal(selectedLanguages)) { + return _resultTranslations; + } else { + _previousLanguages = selectedLanguages; + _resultTranslations = {}; //cp reset + } + //get translations from firestore + //TODO: use ServiceLocator + DocumentSnapshot> data = await _firebaseInstance + .collection('translations') + .doc(id) + .get(); + //neu id chua exist, them vao firestore + if (!data.exists) { + await _firebaseInstance + .collection('translations') + .doc(id) + .set({'id ko ton tai': 'id ko ton tai => them moi'}); + data = await _firebaseInstance.collection('translations').doc(id).get(); + } + Map translations = data.data() ?? {}; + print('translations: $translations'); + List listLangSendToGemini = []; + for (String lang in selectedLanguages) { + bool hasTranslated = false; + for (var e in translations.entries) { + if (lang.toLowerCase() == e.key.toLowerCase()) { + _resultTranslations[lang] = "translated: " + + e.value; //đã có trên firestore rồi, ko cần dịch nữa + hasTranslated = true; + break; + } + } + if (!hasTranslated) { + listLangSendToGemini.add(lang); + } + } + if (listLangSendToGemini.isNotEmpty) { + await sendToGenmini(msg: message, languages: listLangSendToGemini) + .then((map) { + for (var e in map.entries) { + //_resultTranslations add them data + _resultTranslations[e.key] = e.value; + } + _firebaseInstance.collection('translations').doc(id).set( + map, + SetOptions( + merge: true)); //nếu key đã có rồi thì ko thay đổi, chưa thì add + }); + } + return _resultTranslations; + } +} + +extension on List { + //tính luôn cả trường hợp giống nhau nhưng ko đúng thứ tự + bool equal(List selectedLanguages) { + // if (this == null) return false; + if (isEmpty || selectedLanguages.isEmpty) return false; + for (String e in this) { + if (!selectedLanguages.contains(e)) { + return false; + } + } + print('equal: $selectedLanguages'); + return true; + } } class _DefaultPersonWidget extends StatelessWidget { diff --git a/lib/_shared/widgets/my_textfield.dart b/lib/_shared/widgets/my_textfield.dart new file mode 100644 index 0000000..0c29835 --- /dev/null +++ b/lib/_shared/widgets/my_textfield.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +class TextFieldInput extends StatelessWidget { + const TextFieldInput({ + super.key, + this.keyboardType, + this.labelText, + this.style, + this.obscureText = false, + this.autocorrect, + this.onChanged, + this.suffixIcon, + this.suffix, + this.suffixText, + this.suffixStyle, + this.suffixIconColor, + this.suffixIconConstraints, + this.filled, + this.fillColor, + this.readOnly = false, + this.prefixIcon, + this.controller, + this.hintText, + this.hintStyle, + this.errorText, + this.onFieldSubmitted, + this.focusNode, + this.minLines, + this.maxLines, + this.autofocus = false, + this.textInputAction, + }); + final TextInputType? keyboardType; + final String? labelText, hintText; + final bool obscureText; + final bool? autocorrect; + final ValueChanged? onChanged; + final Widget? suffixIcon; + final Widget? prefixIcon; + final Widget? suffix; + final String? suffixText; + final TextStyle? suffixStyle; + final Color? suffixIconColor; + final BoxConstraints? suffixIconConstraints; + final bool? filled; + final Color? fillColor; + final bool readOnly; + final TextEditingController? controller; + final TextStyle? hintStyle; + final String? errorText; + final void Function(String)? onFieldSubmitted; + final FocusNode? focusNode; + final int? minLines; + final int? maxLines; + final TextStyle? style; + final bool autofocus; + final TextInputAction? textInputAction; + @override + Widget build(BuildContext context) { + return TextFormField( + textInputAction: textInputAction, + autofocus: autofocus, + onChanged: onChanged, + style: style ?? (readOnly ? const TextStyle(color: Colors.grey) : null), + maxLines: maxLines, + minLines: minLines, + focusNode: focusNode, + onFieldSubmitted: onFieldSubmitted, + obscureText: obscureText, + controller: controller, + keyboardType: keyboardType, + readOnly: readOnly, + cursorColor: Colors.black, + decoration: InputDecoration( + errorText: errorText, + hintText: hintText, + counterStyle: const TextStyle(color: Colors.pink), + labelStyle: const TextStyle(color: Colors.black), + hintStyle: hintStyle ?? const TextStyle(color: Colors.grey), + prefixIcon: prefixIcon, + suffixIcon: suffixIcon, + suffix: suffix, + contentPadding: + const EdgeInsets.symmetric(vertical: 10, horizontal: 15), + labelText: labelText, + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey)), + focusColor: Colors.black, + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.black)), + border: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey)), + filled: filled ?? true, + fillColor: readOnly + ? (fillColor ?? Colors.grey) + : fillColor ?? + Colors.white, + ), + ); + } +} diff --git a/lib/_shared/widgets/translations_widget.dart b/lib/_shared/widgets/translations_widget.dart new file mode 100644 index 0000000..a3346b4 --- /dev/null +++ b/lib/_shared/widgets/translations_widget.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'chat_bubble_widget.dart'; + +class TranslationsWidget extends StatelessWidget { + const TranslationsWidget({ + super.key, + required this.widget, + required this.translations, + }); + + final ChatBubble widget; + final Map translations; + + @override + Widget build(BuildContext context) { + return Column( + //only translations + children: translations.entries + .where( + (element) => element.key != 'original', + ) + .map( + (e) => Text.rich( + TextSpan(children: [ + TextSpan( + text: '${e.key} ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + color: widget.isMine ? Colors.black87 : Colors.grey)), + TextSpan( + text: e.value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + color: widget.isMine ? Colors.black87 : Colors.grey), + ) + ]), + textAlign: widget.isMine ? TextAlign.right : TextAlign.left, + ), + ) + .toList()); + } +} diff --git a/lib/config/const.dart b/lib/config/const.dart new file mode 100644 index 0000000..f20e344 --- /dev/null +++ b/lib/config/const.dart @@ -0,0 +1 @@ +const maxLanguages = 2; \ No newline at end of file diff --git a/lib/config/routes/navigator.dart b/lib/config/routes/navigator.dart new file mode 100644 index 0000000..3f2ff9a --- /dev/null +++ b/lib/config/routes/navigator.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import '../../main.dart'; + +push(Widget page) { + return navigatorKey.currentState + ?.push(MaterialPageRoute(builder: (context) => page)); +} +pushReplacement(Widget page) { + return navigatorKey.currentState + ?.pushReplacement(MaterialPageRoute(builder: (context) => page)); +} + +pop({dynamic arguments}) { + navigatorKey.currentState?.pop(arguments ?? {}); +} diff --git a/lib/config/themes/app_color.dart b/lib/config/themes/app_color.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/config/themes/my_text_style.dart b/lib/config/themes/my_text_style.dart new file mode 100644 index 0000000..10d2e6c --- /dev/null +++ b/lib/config/themes/my_text_style.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +class MyTextStyle { + static Text errorText(String text) { + return Text(text, + style: const TextStyle( + color: Colors.red, fontSize: 18, fontWeight: FontWeight.bold)); + } + + Text noElement(String text) { + return Text( + text, + style: const TextStyle( + color: Colors.grey, fontSize: 18, fontWeight: FontWeight.bold), + ); + } + + static const translate = + TextStyle(fontSize: 16, color: Colors.amber, fontStyle: FontStyle.italic); + static const greySemiBold = TextStyle( + color: Color.fromRGBO(179, 179, 179, 100), + fontWeight: FontWeight.w600, + fontSize: 14, //11, + ); + + static const titleAppbar = TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + ); + + // static const inboxTextBlack = TextStyle( + // color: Colors.black, + // fontWeight: FontWeight.w500, //medium + // fontSize: 12, + // ); + // static const inboxTextWhite = TextStyle( + // color: Colors.white.withOpacity(1), + // fontWeight: FontWeight.w400, //medium + // fontSize: 16, + // ); + static const heading1 = TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ); + + static const heading2 = TextStyle( + fontWeight: FontWeight.w400, + fontSize: 14, + ); + static const textSelected = TextStyle( + fontWeight: FontWeight.w400, + fontSize: 14, + height: 1.2, + backgroundColor: Colors.brown, //AppColor.backgroundText, + decoration: TextDecoration.none, + ); + static const normal = TextStyle( + fontWeight: FontWeight.w400, + fontSize: 14, + height: 1.2, + decoration: TextDecoration.none, + ); +} diff --git a/lib/features/app_settings/settings_screen.dart b/lib/features/app_settings/settings_screen.dart new file mode 100644 index 0000000..3dc3368 --- /dev/null +++ b/lib/features/app_settings/settings_screen.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'widgets/option_row.dart'; +import 'widgets/switch_button.dart'; + +// ignore: must_be_immutable +class SettingsScreen extends StatelessWidget { + SettingsScreen({super.key}); + + bool autoTranslate = false; + bool useDefaultLocale = true; + bool useFavoriteLang = false; + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: + AppBar(leading: const BackButton(), title: const Text('Cài đặt app')), + body: Column( + children: [ + buildRowOption( + textLeft: 'Tắt dịch toàn bộ tin nhắn', + right: MySwitchButton( + onChange: (value) { + //TODO tat dich + }, + )), + StatefulBuilder(builder: (context, setState) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + buildRowOption( + textLeft: 'Tự động dịch mỗi lần vào app', + right: MySwitchButton( + toggle: autoTranslate, + onChange: (value) { + autoTranslate = value; + setState(() {}); + }, + )), //nếu hiện tại chưa dịch msg, thì kệ nó + if (autoTranslate) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + buildRowOption( + level2: true, + textLeft: 'Sử dụng ngôn ngữ mặc định (locale)', + right: MySwitchButton( + toggle: useDefaultLocale, + onChange: (value) { + useDefaultLocale = value; + useFavoriteLang = !value; + setState(() {}); + }, + )), + buildRowOption( + level2: true, + textLeft: 'Sử dụng ngôn ngữ yêu thích', + right: MySwitchButton( + toggle: useFavoriteLang, + onChange: (value) { + useFavoriteLang = value; + useDefaultLocale = !value; + setState(() {}); + }, + )), + ], + ), + ], + ); + }), + buildRowOption( + textLeft: + 'Dịch nhanh bằng cách bấm vào tin nhắn MỘT lần duy nhất', + right: MySwitchButton( + onChange: (value) { + //TODO one tap + }, + )), //đối với msg chưa đc dịch + buildRowOption( + textLeft: 'Ngôn ngữ yêu thích', + right: IconButton( + onPressed: () { + //TODO favorite lang + }, + icon: const Icon(Icons.add), + )), + ], + ), + ); + } +} diff --git a/lib/features/app_settings/widgets/option_row.dart b/lib/features/app_settings/widgets/option_row.dart new file mode 100644 index 0000000..1ccd7f3 --- /dev/null +++ b/lib/features/app_settings/widgets/option_row.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +Widget buildRowOption( + {String textLeft = '', + Widget? widgetLeft, + required Widget right, + bool level2 = false}) { + return Padding( + padding: + EdgeInsets.only(left: level2 ? 20 : 10, right: 10, top: 8, bottom: 8), + child: Row( + children: [ + Expanded( + child: widgetLeft ?? + Text( + textLeft, + style: TextStyle( + fontSize: level2 ? 16 : 18, + color: level2 ? Colors.grey : null, + ), + // style: GoogleFonts.sarabun( + // fontSize: 18, + // fontWeight: FontWeight.bold, + // ), + ), + ), + SizedBox(width: 100, child: right) + ], + ), + ); +} diff --git a/lib/features/app_settings/widgets/settings_button.dart b/lib/features/app_settings/widgets/settings_button.dart new file mode 100644 index 0000000..2e77671 --- /dev/null +++ b/lib/features/app_settings/widgets/settings_button.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +import '../../../config/routes/navigator.dart'; +import '../settings_screen.dart'; + +class SettingsButton extends StatelessWidget { + const SettingsButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () { + push(SettingsScreen()); + }, + icon: const Icon(Icons.settings)); + } +} diff --git a/lib/features/app_settings/widgets/switch_button.dart b/lib/features/app_settings/widgets/switch_button.dart new file mode 100644 index 0000000..1fa48af --- /dev/null +++ b/lib/features/app_settings/widgets/switch_button.dart @@ -0,0 +1,45 @@ +import 'package:flutter/cupertino.dart'; + +class MySwitchButton extends StatefulWidget { + const MySwitchButton({super.key, this.onChange, this.toggle = false}); + final bool toggle; + final Function(bool value)? onChange; + + @override + State createState() => _MySwitchButtonState(); +} + +class _MySwitchButtonState extends State { + // bool _toggle = false; + // @override + // void initState() { + // super.initState(); + // _toggle = widget.init; + // WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // setState(() {}); + // }); + // } + + @override + Widget build(BuildContext context) { + bool _toggle = widget.toggle; + return CupertinoSwitch( + // overrides the default green color of the track + // activeColor: Colors.pink.shade200, + // color of the round icon, which moves from right to left + // thumbColor: Colors.green.shade900, + // when the switch is off + // trackColor: Colors.black12, + // boolean variable value + value: _toggle, //only do when not null + // changes the state of the switch + onChanged: (value) { + setState(() { + _toggle = value; + print(_toggle); + }); + widget.onChange?.call(value); + }, + ); + } +} diff --git a/lib/features/chat/ui/public_chat_screen.dart b/lib/features/chat/ui/public_chat_screen.dart index ff74eaf..3b6231c 100644 --- a/lib/features/chat/ui/public_chat_screen.dart +++ b/lib/features/chat/ui/public_chat_screen.dart @@ -3,25 +3,34 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_ui_firestore/firebase_ui_firestore.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:public_chat/_shared/bloc/user_manager/user_manager_cubit.dart'; import 'package:public_chat/_shared/data/chat_data.dart'; -import 'package:public_chat/_shared/widgets/chat_bubble_widget.dart'; -import 'package:public_chat/_shared/widgets/message_box_widget.dart'; import 'package:public_chat/features/chat/bloc/chat_cubit.dart'; import 'package:public_chat/utils/locale_support.dart'; +import '../../../_shared/bloc/user_manager/user_manager_cubit.dart'; +import '../../../_shared/dialog/loading_dialog.dart'; +import '../../../_shared/widgets/chat_bubble_widget.dart'; +import '../../../_shared/widgets/message_box_widget.dart'; +import '../../app_settings/widgets/settings_button.dart'; +import '../../translate_settings/widgets/translate_settings_button.dart'; -class PublicChatScreen extends StatelessWidget { +class PublicChatScreen extends StatefulWidget { const PublicChatScreen({super.key}); + @override + State createState() => _PublicChatScreenState(); +} + +class _PublicChatScreenState extends State { @override Widget build(BuildContext context) { final User? user = FirebaseAuth.instance.currentUser; - + print('build PublicChatScreen:${user?.displayName}'); return BlocProvider( create: (context) => ChatCubit(), child: Scaffold( appBar: AppBar( title: Text(context.locale.publicRoomTitle), + actions: const [TranslateSettingsButton(), SettingsButton()], ), body: Column( children: [ @@ -29,61 +38,74 @@ class PublicChatScreen extends StatelessWidget { child: Builder( builder: (context) { return FirestoreListView( - query: context.read().chatContent, - reverse: true, - itemBuilder: (BuildContext context, - QueryDocumentSnapshot doc) { - if (!doc.exists) { - return const SizedBox.shrink(); - } - - final Message message = doc.data(); - - return BlocProvider.value( - value: UserManagerCubit() - ..queryUserDetail(message.sender), - child: - BlocBuilder( - builder: (context, state) { - String? photoUrl; - String? displayName; + query: context.read().chatContent, + reverse: true, + pageSize: 10, + showFetchingIndicator: true, + shrinkWrap: true, + itemBuilder: (BuildContext context, + QueryDocumentSnapshot doc) { + print( + 'build FirestoreListView:${doc.data().toString()}'); + if (!doc.exists) { + return const SizedBox.shrink(); + } + final Message message = doc.data(); + // return SizedBox( + // height: 200, child: Text(message.toString())); + return BlocProvider.value( + value: UserManagerCubit() + ..queryUserDetail(message.sender), + child: + BlocBuilder( + builder: (context, state) { + String? photoUrl; + String? displayName; - if (state is UserDetailState) { - photoUrl = state.photoUrl; - displayName = state.displayName; - } + if (state is UserDetailState) { + photoUrl = state.photoUrl; + displayName = state.displayName; + } - return ChatBubble( - isMine: message.sender == user?.uid, - message: message.message, - photoUrl: photoUrl, - displayName: displayName, - translations: message.translations); - }, - ), - ); - }, - emptyBuilder: (context) => const Center( - child: Text( - 'No messages found. Send the first message now!'), - ), - loadingBuilder: (context) => const Center( - child: CircularProgressIndicator(), - ), - ); + return ChatBubble( + isMine: message.sender == user?.uid, + message: message.message, + photoUrl: photoUrl, + displayName: displayName, + translations: message.translations, + id: doc.id); + }, + ), + ); + }, + fetchingIndicatorBuilder: (context) => + const LoadingState(), + emptyBuilder: (context) => const Center( + child: Text( + 'No messages found. Send the first message now!'), + ), + loadingBuilder: (context) => const LoadingState()); }, ), ), - MessageBox( - onSendMessage: (value) { - if (user == null) { - // do nothing - return; - } - FirebaseFirestore.instance - .collection('public') - .add(Message(sender: user.uid, message: value).toMap()); - }, + Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Đè tin nhắn để hiện tùy chọn'), + MessageBox( + onSendMessage: (value) { + if (user == null) { + // do nothing + return; + } + final msg = Message(sender: user.uid, message: value); + FirebaseFirestore.instance + .collection('public') + .add(msg.toMap()); + }, + ), + ], ) ], )), diff --git a/lib/features/translate_settings/trans_bloc.dart b/lib/features/translate_settings/trans_bloc.dart new file mode 100644 index 0000000..9965c90 --- /dev/null +++ b/lib/features/translate_settings/trans_bloc.dart @@ -0,0 +1,34 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:public_chat/utils/bloc_extensions.dart'; +import 'package:public_chat/utils/local_shared_data.dart'; +part 'trans_event.dart'; +part 'trans_state.dart'; + +class TransBloc extends Bloc { + final LocalSharedData _localSharedData = LocalSharedData(); + TransBloc() : super(TransInit()) { + on(_onSelectLanguage); + on(_onLoadHistoryLanguages); + } + Future _onSelectLanguage( + SelectLanguageEvent event, + Emitter emit, + ) async { + _localSharedData.setChatLanguages(event.languages); + emitSafely( + ChangeLangState( + selectedLanguages: event.languages, + ), + ); + } + + FutureOr _onLoadHistoryLanguages( + LoadHistoryLanguagesEvent event, Emitter emit) { + print('onLoadHistoryLanguages'); + emitSafely(TransInit()); + final listHistoryLanguages = _localSharedData.getListHistoryLanguages(); + emitSafely( + LoadHistoryLanguages(listHistoryLanguages: listHistoryLanguages)); + } +} \ No newline at end of file diff --git a/lib/features/translate_settings/trans_event.dart b/lib/features/translate_settings/trans_event.dart new file mode 100644 index 0000000..44214dd --- /dev/null +++ b/lib/features/translate_settings/trans_event.dart @@ -0,0 +1,11 @@ +part of 'trans_bloc.dart'; + +abstract class TransEvent {} + +class SelectLanguageEvent extends TransEvent { + final List languages; + + SelectLanguageEvent(this.languages); +} + +class LoadHistoryLanguagesEvent extends TransEvent {} diff --git a/lib/features/translate_settings/trans_state.dart b/lib/features/translate_settings/trans_state.dart new file mode 100644 index 0000000..5704fc0 --- /dev/null +++ b/lib/features/translate_settings/trans_state.dart @@ -0,0 +1,27 @@ +part of 'trans_bloc.dart'; + +abstract class TransState {} + +class TransInit extends TransState { + TransInit(); +} + +class SelectLangError extends TransState { + final String message; + SelectLangError({ + required this.message, + }); +} + +class ChangeLangState extends TransState { + final List selectedLanguages; + + ChangeLangState({ + required this.selectedLanguages, + }); +} + +class LoadHistoryLanguages extends TransState { + final List listHistoryLanguages; + LoadHistoryLanguages({required this.listHistoryLanguages}); +} diff --git a/lib/features/translate_settings/translate_popup.dart b/lib/features/translate_settings/translate_popup.dart new file mode 100644 index 0000000..f8f7a9a --- /dev/null +++ b/lib/features/translate_settings/translate_popup.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import '../../_shared/widgets/my_textfield.dart'; +import 'widgets/list_hint.dart'; + +class TranslatePopup extends StatelessWidget { + TranslatePopup({ + super.key, + required this.onSubmit, + required this.fetchListHistoryLanguages, + }); + final void Function(String value) onSubmit; + final Future> Function() fetchListHistoryLanguages; + + final _controller = TextEditingController(); + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 10), + ListHintWidget( + fetchListData: fetchListHistoryLanguages, + onSelect: (value) { + _controller.text += '$value '; + }, + onUnSelect: (value) { + _controller.text = _controller.text.replaceAll('$value ', ''); + }, + ), + const SizedBox(height: 10), + Text( + 'ví dụ: vi en... (cách nhau bởi dấu cách)', + style: Theme.of(context).textTheme.bodyMedium, + ), + TextFieldInput( + autofocus: true, + hintText: "Nhập 1 hoặc nhiều ngôn ngữ/ mã ngôn ngữ", + controller: _controller, + textInputAction: TextInputAction.done, + onFieldSubmitted: (value) { + onSubmit(value); + }, + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/translate_settings/widgets/hint_widget.dart b/lib/features/translate_settings/widgets/hint_widget.dart new file mode 100644 index 0000000..58d1594 --- /dev/null +++ b/lib/features/translate_settings/widgets/hint_widget.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class HintWidget extends StatelessWidget { + const HintWidget( + {super.key, + required this.name, + this.width, + this.isSelected = false, + required this.onTap}); + final String name; + final double? width; + final bool isSelected; + final void Function() onTap; + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: width, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), //20 + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFF121212)), + borderRadius: BorderRadius.circular(30), + color: + isSelected ? const Color(0xFF121212) : const Color(0xFFFFFFFF)), + child: Text( + name, + style: TextStyle( + fontWeight: FontWeight.w500, + color: + isSelected ? const Color(0xFFFFFFFF) : const Color(0xFF121212), + fontSize: 15, + ), + // style: GoogleFonts.getFont( + // 'Poppins', + // fontWeight: FontWeight.w500, + // color: + // isSelected ? const Color(0xFFFFFFFF) : const Color(0xFF121212), + // fontSize: 15, + // ), + ), + ), + ); + } +} diff --git a/lib/features/translate_settings/widgets/list_hint.dart b/lib/features/translate_settings/widgets/list_hint.dart new file mode 100644 index 0000000..8049ff1 --- /dev/null +++ b/lib/features/translate_settings/widgets/list_hint.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import '../../../_shared/dialog/loading_dialog.dart'; +import 'hint_widget.dart'; + +//fetchListData noi bo trong class nay +class ListHintWidget extends StatefulWidget { + const ListHintWidget({ + super.key, + this.width, + this.widthNull = false, + required this.fetchListData, + required this.onSelect, + required this.onUnSelect, + }); + final Future> Function() fetchListData; + final double? width; + final bool widthNull; + final void Function(T) onSelect; + final void Function(T) onUnSelect; + @override + State> createState() => _ListHintWidgetState(); +} + +class _ListHintWidgetState extends State> { + // @override + // void didUpdateWidget(covariant ListStatusWidget oldWidget) {//TODO to what? + // if ((widget.selectedIndex ?? 0) >= 0) + // selectedIndex = widget.selectedIndex ?? 0; + // super.didUpdateWidget(oldWidget); + // } + + List? _listData; + @override + initState() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + // if (_listData == null) {//vì history luôn đổi dù previous có empty hay ko thì cx phải fetch cái mới + _listData = await widget.fetchListData(); + setState(() {}); + // } + }); + // if ((widget.selectedIndex ?? 0) >= 0) + // selectedIndex = widget.selectedIndex ?? 0; + super.initState(); + } + + List selectedIndex = []; + @override + Widget build(BuildContext context) { + if (_listData == null) return const LoadingState(); + return Wrap( + spacing: 4, //ngang + runSpacing: 4, //doc + children: _listData! + .asMap() + .entries + .map((entry) => HintWidget( + // width: widget.widthNull + // ? null + // : (widget.width ?? + // MediaQuery.sizeOf(context).width / 2 - 25), //16+9 + name: entry.value.toString(), + isSelected: selectedIndex.contains(entry.key), + onTap: () { + if (selectedIndex.contains(entry.key)) { + selectedIndex.remove(entry.key); + widget.onUnSelect.call(entry.value); + } else { + selectedIndex.add(entry.key); + widget.onSelect.call(entry.value); + } + setState(() {}); + }, + )) + .toList(), + ); + } +} diff --git a/lib/features/translate_settings/widgets/translate_settings_button.dart b/lib/features/translate_settings/widgets/translate_settings_button.dart new file mode 100644 index 0000000..c1509df --- /dev/null +++ b/lib/features/translate_settings/widgets/translate_settings_button.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../_shared/dialog/message_dialog.dart'; +import '../../../config/routes/navigator.dart'; +import '../../../utils/local_shared_data.dart'; +import '../trans_bloc.dart'; +import '../translate_popup.dart'; + +class TranslateSettingsButton extends StatelessWidget { + const TranslateSettingsButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () { + showSelectLang(); + }, + icon: const Icon(Icons.translate), + ); + } +} + +void showSelectLang() { + List _getHistoryLanguages() { + return LocalSharedData().getListHistoryLanguages(); + } + MessageDialog.showMessageDialog( + titleText: 'Chọn ngôn ngữ dịch cho toàn bộ tin nhắn', + contentWidget: BlocBuilder( + builder: (context, state) { + return TranslatePopup( + onSubmit: (value) { + pop(); + final languages = value + .split(' ') + // .where((e) => e.isNotEmpty) + .map((e) => e.trim().toLowerCase()) + .toList(); + languages.removeWhere((e) => e.isEmpty); + print('languages: $languages'); + // if (languages.length > maxLanguages) { + // languages.sublist(0, maxLanguages); + // } + context.read().add(SelectLanguageEvent(languages)); + LocalSharedData().setChatLanguagesAndHistoryLanguages(languages); + }, + fetchListHistoryLanguages: () async => _getHistoryLanguages(), + ); + }, + ), + tapOutsideToClose: true, + showCloseButton: false, + ); +} diff --git a/lib/main.dart b/lib/main.dart index 4738fa9..f788ca9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,23 +1,34 @@ +import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:public_chat/features/genai_setting/bloc/genai_bloc.dart'; -import 'package:public_chat/features/login/ui/login_screen.dart'; import 'package:public_chat/firebase_options.dart'; import 'package:public_chat/service_locator/service_locator.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'features/login/ui/login_screen.dart'; +import 'features/translate_settings/trans_bloc.dart'; +import 'utils/global.dart'; +final defaultLanguageCode = Platform.localeName; +BuildContext? get globalAppContext => navigatorKey.currentContext; +final GlobalKey navigatorKey = GlobalKey(); void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); ServiceLocator.instance.initialise(); - - runApp(BlocProvider( - create: (context) => GenaiBloc(), - child: const MainApp(), - )); + await Global().init(); + runApp( + MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => GenaiBloc()), + BlocProvider(create: (context) => TransBloc()), + ], + child: const MainApp(), + ), + ); } class MainApp extends StatelessWidget { @@ -25,14 +36,15 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( - localizationsDelegates: [ + return MaterialApp( + navigatorKey: navigatorKey, + localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - home: LoginScreen()); + home: const LoginScreen()); } } diff --git a/lib/utils/global.dart b/lib/utils/global.dart new file mode 100644 index 0000000..7afaa35 --- /dev/null +++ b/lib/utils/global.dart @@ -0,0 +1,10 @@ +import 'local_shared_data.dart'; + +class Global { + Global._internal(); + static final Global _instance = Global._internal(); + factory Global() => _instance; + init() async { + await LocalSharedData().init(); + } +} diff --git a/lib/utils/local_shared_data.dart b/lib/utils/local_shared_data.dart new file mode 100644 index 0000000..2452285 --- /dev/null +++ b/lib/utils/local_shared_data.dart @@ -0,0 +1,60 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../main.dart'; + +const keyIsFirstOpenKey = 'is_first_open'; +const keyLoginModel = 'login_model'; +const keyUser = 'user'; +const keyChatLanguages = 'chat_languages'; //current languages +const keyListHistoryLanguages = 'list_history_languages'; + +class LocalSharedData { + LocalSharedData._internal(); + static LocalSharedData instance = LocalSharedData._internal(); + factory LocalSharedData() { + return instance; + } + + late SharedPreferences sharedPreferences; + + init() async { + sharedPreferences = await SharedPreferences.getInstance(); + } + + saveIsFirstOpen() async { + sharedPreferences.setBool(keyIsFirstOpenKey, false); + } + + List listHistoryLanguages = [defaultLanguageCode, 'english']; + setChatLanguagesAndHistoryLanguages(List languages) async { + await setChatLanguages(languages); + await setListHistoryLanguages(languages); + } + + setChatLanguages(List languages) async { + await sharedPreferences.setStringList(keyChatLanguages, languages); + } + + setListHistoryLanguages(List languages) async { + for (var language in languages) { + if (listHistoryLanguages.contains(language)) { + return; //already exist + } else { + listHistoryLanguages.add(language); + await sharedPreferences.setStringList( + keyListHistoryLanguages, listHistoryLanguages); + } + } + } + + List getListHistoryLanguages() { + List list = + sharedPreferences.getStringList(keyListHistoryLanguages) ?? + [defaultLanguageCode, 'en']; + print('getListHistoryLanguages: $list'); + return list; + } + + List? getChatLanguages() { + return sharedPreferences.getStringList(keyChatLanguages); + } +} diff --git a/lib/utils/my_translator.dart b/lib/utils/my_translator.dart new file mode 100644 index 0000000..66819fe --- /dev/null +++ b/lib/utils/my_translator.dart @@ -0,0 +1,19 @@ +import 'package:translator/translator.dart'; + +import '../main.dart'; + +Future translate(String text, + {String? to, String from = 'auto'}) async { + try { + return (await GoogleTranslator().translate(text, + // to: 'vi', from: 'auto', + to: to ?? defaultLanguageCode, + from: from)) + .text; + } catch (e) { + print('translate error: $e'); + // MessageDialog.showError("Không tìm thấy ngôn ngữ '$to'\n${e}", + // titleText: 'Dịch thất bại (dịch từ ngôn ngữ "$from")'); + return "Không tìm thấy ngôn ngữ '$to' (dịch từ ngôn ngữ '$from')"; + } +} diff --git a/lib/utils/send_to_gemini.dart b/lib/utils/send_to_gemini.dart new file mode 100644 index 0000000..fb9550d --- /dev/null +++ b/lib/utils/send_to_gemini.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../_shared/dialog/message_dialog.dart'; + +Future> sendToGenmini({ + required String msg, + required List languages, +}) async { + String? apiKey = Platform.environment['GEMINI_API_KEY']; + if (apiKey == null) { + stderr.writeln(r'No $GEMINI_API_KEY environment variable'); + exit(1); + } + final model = GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: apiKey, + generationConfig: GenerationConfig( + temperature: 1, + topK: 40, + topP: 0.95, + maxOutputTokens: 8192, + responseMimeType: 'text/plain', + ), + ); + final generationConfig = GenerationConfig( + temperature: 1, + topP: 0.95, + topK: 64, + maxOutputTokens: 8192, + responseMimeType: 'application/json', + responseSchema: Schema(SchemaType.object, properties: { + 'translations': Schema( + SchemaType.object, + properties: Map.fromEntries( + languages.map((lang) => MapEntry(lang, Schema(SchemaType.string)))), + requiredProperties: languages, + ) + }, requiredProperties: [ + 'translations' + ]), + ); + final prompt = + '''Translate "$msg" to these languages/language codes: ${languages.toString()}, + You must return the translations in JSON format with language codes as keys, e.g: {"en": "English translation", "fr": "French translation"}'''; + final chatSession = model.startChat(generationConfig: generationConfig); + final result = await chatSession.sendMessage(Content.text(prompt)); + final jsonTranslated = result.text; + print( + 'translated json: $jsonTranslated'); // {"translations": {"en": "Hello, this is a test. I am Dương"}} + Map? translated; + if (jsonTranslated != null) { + translated = jsonDecode(jsonTranslated)['translations']; + print( + 'final result: $translated'); //{en: Hello, this is a test. I am Dương} + } else { + MessageDialog.showError('Gemini trả về phản hồi null'); + } + return translated ?? {}; +} diff --git a/pubspec.yaml b/pubspec.yaml index b74a4eb..f36caa3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,8 +23,11 @@ dependencies: google_generative_ai: ^0.4.3 google_sign_in: ^6.2.1 google_sign_in_web: ^0.12.4+2 + http: ^1.2.2 image_network: ^2.5.6 intl: ^0.19.0 + shared_preferences: ^2.3.2 + translator: ^1.0.0 dev_dependencies: flutter_test: diff --git a/test/_shared/widgets/chat_bubble_widget_test.dart b/test/_shared/widgets/chat_bubble_widget_test.dart index 191b9cb..95c5e63 100644 --- a/test/_shared/widgets/chat_bubble_widget_test.dart +++ b/test/_shared/widgets/chat_bubble_widget_test.dart @@ -1,17 +1,51 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image_network/image_network.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:public_chat/_shared/button/button_with_popup.dart'; import 'package:public_chat/_shared/widgets/chat_bubble_widget.dart'; - +import 'package:public_chat/features/translate_settings/trans_bloc.dart'; +import 'package:public_chat/main.dart'; import '../../material_wrapper_extension.dart'; +class MockTransBloc extends Mock implements TransBloc {} + void main() { + final MockTransBloc transBloc = MockTransBloc(); + + setUp( + () { + when( + () => transBloc.state, + ).thenAnswer( + (_) => ChangeLangState( + selectedLanguages: [defaultLanguageCode], + ), + ); + when( + () => transBloc.stream, + ).thenAnswer( + (_) => const Stream.empty(), + ); + when( + () => transBloc.close(), + ).thenAnswer( + (invocation) => Future.value(), + ); + }, + ); + testWidgets('verify UI component', (widgetTester) async { - const Widget widget = ChatBubble( - isMine: true, - message: 'message', - displayName: 'displayName', - photoUrl: null, + Widget widget = BlocProvider( + create: (context) => transBloc, + child: ChatBubble( + isMine: true, + message: 'message', + displayName: 'displayName', + photoUrl: null, + id: 'id', + ), ); await widgetTester.wrapAndPump(widget); @@ -63,11 +97,15 @@ void main() { ' then CachedNetworkImage is not present, and Icon with data Icons.person is present', (widgetTester) async { // given - const Widget widget = ChatBubble( - isMine: true, - message: 'message', - displayName: 'displayName', - photoUrl: null, + Widget widget = BlocProvider( + create: (context) => transBloc, + child: ChatBubble( + isMine: true, + message: 'message', + displayName: 'displayName', + photoUrl: null, + id: 'id', + ), ); // when @@ -89,11 +127,15 @@ void main() { ' when load ChatBubble,' ' then CachedNetworkImage is present', (widgetTester) async { // given - const Widget widget = ChatBubble( - isMine: true, - photoUrl: 'photoUrl', - message: 'message', - displayName: 'displayName', + Widget widget = BlocProvider( + create: (context) => transBloc, + child: ChatBubble( + isMine: true, + photoUrl: 'photoUrl', + message: 'message', + displayName: 'displayName', + id: 'id', + ), ); // when @@ -110,11 +152,16 @@ void main() { ' and Container with Text is first item in row,' ' and Padding with ClipRRect is last item in row', (widgetTester) async { // given - const Widget widget = ChatBubble( + Widget widget = BlocProvider( + create: (context) => transBloc, + child: ChatBubble( isMine: true, photoUrl: 'photoUrl', message: 'message', - displayName: 'displayName'); + displayName: 'displayName', + id: 'id', + ), + ); // when await widgetTester.wrapAndPump(widget); @@ -132,7 +179,7 @@ void main() { expect(row.children.length, 2); // verify Container wrapper for Text is present - expect(row.children.first, isA()); + expect(row.children.first, isA()); // verify Padding wrapper for ClipRRect is preset expect(row.children.last, isA()); }); @@ -144,11 +191,16 @@ void main() { ' and Padding with ClipRRect is first item in row,' ' and Container with Text is second item in row', (widgetTester) async { // given - const Widget widget = ChatBubble( + Widget widget = BlocProvider( + create: (context) => transBloc, + child: ChatBubble( isMine: false, photoUrl: 'photoUrl', message: 'message', - displayName: 'displayName'); + displayName: 'displayName', + id: 'id', + ), + ); // when await widgetTester.wrapAndPump(widget); @@ -168,6 +220,6 @@ void main() { // verify Padding wrapper for ClipRRect is preset expect(row.children.first, isA()); // verify Container wrapper for Text is present - expect(row.children.last, isA()); + expect(row.children.last, isA()); }); }