From 60b659040979e0db407920f00b181e748fb1a69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BC=96=E7=A0=81=E5=B7=A5=E7=A8=8B=E5=B8=88?= Date: Thu, 20 Aug 2026 02:30:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile-next):=20=E5=B7=A5=E5=8F=B7?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=B8=8E=E7=9B=AE=E5=BD=95=E8=96=84=E9=80=82?= =?UTF-8?q?=E9=85=8D=EF=BC=8C=E5=B9=B6=E5=AE=8C=E6=88=90=E7=99=BB=E5=BD=95?= =?UTF-8?q?/=E4=BC=9A=E8=AF=9D/=E8=81=8A=E5=A4=A9/=E9=80=9A=E8=AE=AF?= =?UTF-8?q?=E5=BD=95=E7=AC=AC=E4=B8=80=E6=89=B9=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor Co-authored-by: multica-agent --- mobile-next/lib/company/auth/auth_api.dart | 80 +++++++ .../lib/company/auth/company_login_logic.dart | 69 ++++++ .../lib/company/directory/directory_api.dart | 73 +++++++ .../lib/company/ui/add_colleague_page.dart | 194 +++++++++++++++++ .../lib/company/ui/company_login_page.dart | 202 ++++++++++++++++++ mobile-next/lib/pages/chat/chat_view.dart | 4 +- .../lib/pages/contacts/contacts_logic.dart | 2 +- .../lib/pages/contacts/contacts_view.dart | 127 ++++++----- .../friend_requests_logic.dart | 25 ++- .../friend_requests/friend_requests_view.dart | 131 +++++++----- .../conversation/conversation_logic.dart | 2 +- .../pages/conversation/conversation_view.dart | 70 +++--- mobile-next/lib/routes/app_navigator.dart | 4 +- mobile-next/lib/routes/app_pages.dart | 15 +- mobile-next/lib/routes/app_routes.dart | 1 + .../lib/src/widgets/chat/chat_bubble.dart | 2 +- .../lib/src/widgets/chat/chat_item_view.dart | 7 +- 17 files changed, 842 insertions(+), 166 deletions(-) create mode 100644 mobile-next/lib/company/auth/auth_api.dart create mode 100644 mobile-next/lib/company/auth/company_login_logic.dart create mode 100644 mobile-next/lib/company/directory/directory_api.dart create mode 100644 mobile-next/lib/company/ui/add_colleague_page.dart create mode 100644 mobile-next/lib/company/ui/company_login_page.dart diff --git a/mobile-next/lib/company/auth/auth_api.dart b/mobile-next/lib/company/auth/auth_api.dart new file mode 100644 index 0000000..e7aeef6 --- /dev/null +++ b/mobile-next/lib/company/auth/auth_api.dart @@ -0,0 +1,80 @@ +import 'package:dio/dio.dart'; + +import '../config/app_endpoints.dart'; + +class AuthResult { + final String userID; + final String imToken; + final String nickname; + + AuthResult({required this.userID, required this.imToken, required this.nickname}); + + factory AuthResult.fromJson(Map json) => AuthResult( + userID: json['userID']?.toString() ?? '', + imToken: json['imToken']?.toString() ?? '', + nickname: json['nickname']?.toString() ?? '', + ); +} + +class AuthException implements Exception { + final String message; + final bool network; + + AuthException(this.message, {this.network = false}); + + @override + String toString() => message; +} + +/// 公司工号登录。只走账号服务 POST /api/login,不改 OpenIM Server。 +class AuthApi { + AuthApi({Dio? dio}) + : _dio = dio ?? + Dio(BaseOptions( + baseUrl: AppEndpoints.authApiBase, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + final Dio _dio; + + Map _unwrap(dynamic body) { + if (body is Map) { + if (body['code'] == 0 && body['data'] is Map) { + return Map.from(body['data'] as Map); + } + final msg = body['msg']?.toString() ?? ''; + throw AuthException(msg.isNotEmpty ? msg : '工号或密码不正确'); + } + throw AuthException('工号或密码不正确'); + } + + Future login({ + required String staffNo, + required String password, + required int platformID, + }) async { + try { + final resp = await _dio.post('/api/login', data: { + 'staffNo': staffNo, + 'password': password, + 'platformID': platformID, + }); + final result = AuthResult.fromJson(_unwrap(resp.data)); + if (result.userID.isEmpty || result.imToken.isEmpty) { + throw AuthException('工号或密码不正确'); + } + return result; + } on DioException catch (e) { + final data = e.response?.data; + if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) { + throw AuthException(data['msg'].toString()); + } + throw AuthException('网络异常,请稍后重试', network: true); + } on AuthException { + rethrow; + } catch (_) { + throw AuthException('网络异常,请稍后重试', network: true); + } + } +} diff --git a/mobile-next/lib/company/auth/company_login_logic.dart b/mobile-next/lib/company/auth/company_login_logic.dart new file mode 100644 index 0000000..873e0df --- /dev/null +++ b/mobile-next/lib/company/auth/company_login_logic.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:openim/core/controller/im_controller.dart'; +import 'package:openim/pages/conversation/conversation_logic.dart'; +import 'package:openim/routes/app_navigator.dart'; +import 'package:openim_common/openim_common.dart'; + +import 'auth_api.dart'; + +class CompanyLoginLogic extends GetxController { + final staffCtrl = TextEditingController(); + final pwdCtrl = TextEditingController(); + final obscure = true.obs; + final submitting = false.obs; + final staffError = RxnString(); + final pwdError = RxnString(); + final toast = RxnString(); + + final _auth = AuthApi(); + final _im = Get.find(); + + @override + void onClose() { + staffCtrl.dispose(); + pwdCtrl.dispose(); + super.onClose(); + } + + void toggleObscure() => obscure.toggle(); + + Future submit() async { + if (submitting.value) return; + final staffNo = staffCtrl.text.trim(); + final password = pwdCtrl.text; + staffError.value = staffNo.isEmpty ? '请输入工号' : null; + pwdError.value = password.isEmpty ? '请输入密码' : null; + if (staffNo.isEmpty || password.isEmpty) return; + + submitting.value = true; + toast.value = null; + try { + final result = await _auth.login( + staffNo: staffNo, + password: password, + platformID: IMUtils.getPlatform(), + ); + final cert = LoginCertificate.fromJson({ + 'userID': result.userID, + 'imToken': result.imToken, + 'chatToken': result.imToken, + }); + await DataSp.putLoginCertificate(cert); + await DataSp.putLoginAccount({'phoneNumber': staffNo, 'loginType': 2}); + await DataSp.putLoginType(2); + Logger.print('company login : ${result.userID}'); + await _im.login(result.userID, result.imToken); + final conversations = await ConversationLogic.getConversationFirstPage(); + Get.find().resetCache(); + AppNavigator.startMain(conversations: conversations); + } on AuthException catch (e) { + toast.value = e.network ? '网络异常,请稍后重试' : '工号或密码不正确'; + } catch (e, s) { + Logger.print('company login e: $e $s'); + toast.value = '网络异常,请稍后重试'; + } finally { + submitting.value = false; + } + } +} diff --git a/mobile-next/lib/company/directory/directory_api.dart b/mobile-next/lib/company/directory/directory_api.dart new file mode 100644 index 0000000..277caee --- /dev/null +++ b/mobile-next/lib/company/directory/directory_api.dart @@ -0,0 +1,73 @@ +import 'package:dio/dio.dart'; +import 'package:openim_common/openim_common.dart'; + +import '../auth/auth_api.dart'; +import '../config/app_endpoints.dart'; + +class DirectoryUser { + final String userID; + final String nickname; + final String department; + final String title; + final String faceURL; + + DirectoryUser({ + required this.userID, + required this.nickname, + required this.department, + required this.title, + required this.faceURL, + }); + + factory DirectoryUser.fromJson(Map json) => DirectoryUser( + userID: json['userID']?.toString() ?? '', + nickname: json['nickname']?.toString() ?? '', + department: json['department']?.toString() ?? '', + title: json['title']?.toString() ?? '', + faceURL: json['faceURL']?.toString() ?? '', + ); +} + +/// 员工目录。数据来自账号服务 GET /api/directory,发起好友仍走官方 SDK。 +class DirectoryApi { + DirectoryApi({Dio? dio}) + : _dio = dio ?? + Dio(BaseOptions( + baseUrl: AppEndpoints.authApiBase, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + final Dio _dio; + + Future> search({String keyword = '', int limit = 20}) async { + final token = DataSp.imToken; + if (token == null || token.isEmpty) { + throw AuthException('登录已过期,请重新登录'); + } + try { + final resp = await _dio.get( + '/api/directory', + queryParameters: {'keyword': keyword, 'limit': limit}, + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + final body = resp.data; + if (body is Map && body['code'] == 0 && body['data'] is Map) { + final items = (body['data']['items'] as List?) ?? const []; + return items + .whereType() + .map((e) => DirectoryUser.fromJson(Map.from(e))) + .where((e) => e.userID.isNotEmpty) + .toList(); + } + final msg = body is Map ? body['msg']?.toString() : null; + throw AuthException((msg != null && msg.isNotEmpty) ? msg : '员工目录暂不可用'); + } on DioException catch (e) { + final data = e.response?.data; + if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) { + throw AuthException(data['msg'].toString()); + } + throw AuthException('网络异常,请稍后重试', network: true); + } + } +} diff --git a/mobile-next/lib/company/ui/add_colleague_page.dart b/mobile-next/lib/company/ui/add_colleague_page.dart new file mode 100644 index 0000000..b01cd23 --- /dev/null +++ b/mobile-next/lib/company/ui/add_colleague_page.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; +import 'package:get/get.dart'; +import 'package:openim_common/openim_common.dart'; + +import '../auth/auth_api.dart'; +import '../brand/app_tokens.dart'; +import '../directory/directory_api.dart'; + +class AddColleagueLogic extends GetxController { + final staffCtrl = TextEditingController(); + final msgCtrl = TextEditingController(text: '你好,我想加你为同事'); + final sending = false.obs; + final staffError = RxnString(); + final directoryHint = RxnString(); + + final _directory = DirectoryApi(); + + @override + void onClose() { + staffCtrl.dispose(); + msgCtrl.dispose(); + super.onClose(); + } + + Future lookupDirectory() async { + final id = staffCtrl.text.trim(); + if (id.isEmpty) { + directoryHint.value = null; + return; + } + try { + final items = await _directory.search(keyword: id, limit: 5); + final hit = items.where((e) => e.userID == id).toList(); + if (hit.isNotEmpty) { + directoryHint.value = hit.first.nickname; + } else if (items.isNotEmpty) { + directoryHint.value = items.map((e) => '${e.nickname}(${e.userID})').join('、'); + } else { + directoryHint.value = null; + } + } catch (_) { + directoryHint.value = null; + } + } + + Future send() async { + if (sending.value) return; + final id = staffCtrl.text.trim(); + if (id.isEmpty) { + staffError.value = '请输入对方工号'; + return; + } + staffError.value = null; + sending.value = true; + try { + await OpenIM.iMManager.friendshipManager.addFriend( + userID: id, + reason: msgCtrl.text.trim().isEmpty ? '你好,我想加你为同事' : msgCtrl.text.trim(), + ); + IMViews.showToast('申请已发送,等对方通过'); + Get.back(); + } on AuthException catch (e) { + IMViews.showToast(e.message); + } catch (_) { + IMViews.showToast('发送失败,请确认工号正确、网络正常'); + } finally { + sending.value = false; + } + } +} + +class AddColleaguePage extends StatelessWidget { + const AddColleaguePage({super.key}); + + @override + Widget build(BuildContext context) { + final logic = Get.put(AddColleagueLogic()); + return Scaffold( + backgroundColor: AppColors.pageBg, + appBar: AppBar( + title: const Text('添加同事'), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: AppIconSize.md), + onPressed: Get.back, + ), + ), + body: Padding( + padding: const EdgeInsets.all(AppGap.x4), + child: Column( + children: [ + Obx(() => _box( + controller: logic.staffCtrl, + hint: '请输入对方工号', + height: 48, + error: logic.staffError.value, + enabled: !logic.sending.value, + onChanged: (_) { + logic.staffError.value = null; + logic.lookupDirectory(); + }, + )), + Obx(() { + final hint = logic.directoryHint.value; + if (hint == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: AppGap.x2), + child: Align( + alignment: Alignment.centerLeft, + child: Text(hint, style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + ), + ); + }), + const SizedBox(height: AppGap.x3), + _box( + controller: logic.msgCtrl, + hint: '你好,我想加你为同事', + height: 96, + maxLines: 4, + enabled: !logic.sending.value, + ), + const Spacer(), + Obx(() { + final busy = logic.sending.value; + return SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + onPressed: busy ? null : logic.send, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + disabledBackgroundColor: AppColors.primary.withOpacity(0.4), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.control)), + ), + child: busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text('发送申请', style: TextStyle(fontSize: AppFont.body, color: Colors.white)), + ), + ); + }), + const SizedBox(height: AppGap.x4), + ], + ), + ), + ); + } + + Widget _box({ + required TextEditingController controller, + required String hint, + required double height, + int maxLines = 1, + String? error, + bool enabled = true, + ValueChanged? onChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2), + decoration: BoxDecoration( + color: AppColors.searchBg, + borderRadius: BorderRadius.circular(AppRadius.control), + border: Border.all(color: error != null ? AppColors.danger : Colors.transparent), + ), + child: TextField( + controller: controller, + maxLines: maxLines, + enabled: enabled, + onChanged: onChanged, + style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: hint, + hintStyle: const TextStyle(fontSize: AppFont.body, color: AppColors.textSecondary), + ), + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: AppGap.x1), + child: Text(error, style: const TextStyle(fontSize: AppFont.small, color: AppColors.danger)), + ), + ], + ); + } +} diff --git a/mobile-next/lib/company/ui/company_login_page.dart b/mobile-next/lib/company/ui/company_login_page.dart new file mode 100644 index 0000000..ed73103 --- /dev/null +++ b/mobile-next/lib/company/ui/company_login_page.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:openim_common/openim_common.dart'; + +import '../auth/company_login_logic.dart'; +import '../brand/app_tokens.dart'; +import '../feature_flags.dart'; + +/// 登录页第一批:v2 布局 + Token,保留 OpenIM 标识,不做畅联换标。 +class CompanyLoginPage extends StatelessWidget { + const CompanyLoginPage({super.key}); + + @override + Widget build(BuildContext context) { + final logic = Get.find(); + return Scaffold( + backgroundColor: AppColors.pageBg, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: AppGap.x8), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + children: [ + const Spacer(flex: 2), + _logo(), + const SizedBox(height: AppGap.x4), + Text( + FeatureFlags.brandMigration ? '畅联' : 'OpenIM', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: AppGap.x1), + const Text( + '企业内部通讯', + style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary), + ), + const SizedBox(height: AppGap.x8), + Obx(() => _field( + controller: logic.staffCtrl, + hint: '请输入工号', + icon: Icons.person_outline, + error: logic.staffError.value, + enabled: !logic.submitting.value, + )), + const SizedBox(height: AppGap.x3), + Obx(() => _field( + controller: logic.pwdCtrl, + hint: '请输入密码', + icon: Icons.lock_outline, + obscure: logic.obscure.value, + onToggleObscure: logic.toggleObscure, + error: logic.pwdError.value, + enabled: !logic.submitting.value, + onSubmitted: (_) => logic.submit(), + )), + const SizedBox(height: AppGap.x4), + Obx(() => _loginButton(logic)), + const SizedBox(height: AppGap.x3), + const Text( + '登录即代表同意内部使用规范', + style: TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary), + ), + Obx(() { + final msg = logic.toast.value; + if (msg == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: AppGap.x3), + child: Text( + msg, + style: const TextStyle(fontSize: AppFont.sub, color: AppColors.danger), + ), + ); + }), + const Spacer(flex: 3), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } + + Widget _logo() { + return ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.logo), + child: Container( + width: 72, + height: 72, + color: AppColors.primary, + alignment: Alignment.center, + child: ImageRes.loginLogo.toImage + ..width = 48 + ..height = 48, + ), + ); + } + + Widget _field({ + required TextEditingController controller, + required String hint, + required IconData icon, + String? error, + bool obscure = false, + VoidCallback? onToggleObscure, + bool enabled = true, + ValueChanged? onSubmitted, + }) { + final borderColor = error != null ? AppColors.danger : Colors.transparent; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 48, + decoration: BoxDecoration( + color: AppColors.searchBg, + borderRadius: BorderRadius.circular(AppRadius.control), + border: Border.all(color: borderColor, width: error != null ? 1 : 0), + ), + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3), + child: Row( + children: [ + Icon(icon, size: AppIconSize.sm, color: AppColors.textSecondary), + const SizedBox(width: AppGap.x2), + Expanded( + child: TextField( + controller: controller, + enabled: enabled, + obscureText: obscure, + onSubmitted: onSubmitted, + inputFormatters: [LengthLimitingTextInputFormatter(32)], + style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: hint, + hintStyle: const TextStyle(fontSize: AppFont.body, color: AppColors.textSecondary), + ), + ), + ), + if (onToggleObscure != null) + IconButton( + onPressed: onToggleObscure, + icon: Icon( + obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: AppIconSize.sm, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: AppGap.x1, left: AppGap.x1), + child: Text(error, style: const TextStyle(fontSize: AppFont.small, color: AppColors.danger)), + ), + ], + ); + } + + Widget _loginButton(CompanyLoginLogic logic) { + final busy = logic.submitting.value; + return SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + onPressed: busy ? null : logic.submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + disabledBackgroundColor: AppColors.primary.withOpacity(0.4), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.control)), + ), + child: busy + ? const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + SizedBox(width: AppGap.x2), + Text('登录中', style: TextStyle(fontSize: AppFont.body, color: Colors.white)), + ], + ) + : const Text('登 录', style: TextStyle(fontSize: AppFont.body, color: Colors.white)), + ), + ); + } +} diff --git a/mobile-next/lib/pages/chat/chat_view.dart b/mobile-next/lib/pages/chat/chat_view.dart index 48fa8f1..f190d1e 100644 --- a/mobile-next/lib/pages/chat/chat_view.dart +++ b/mobile-next/lib/pages/chat/chat_view.dart @@ -3,6 +3,7 @@ import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; import 'package:get/get.dart'; import 'package:openim_common/openim_common.dart'; +import '../../company/feature_flags.dart'; import '../../widgets/file_download_progress.dart'; import 'chat_logic.dart'; @@ -222,12 +223,13 @@ class ChatPage extends StatelessWidget { onCloseMultiModel: logic.exit, onClickMoreBtn: logic.chatSetup, onClickCallBtn: logic.call, + showCallBtn: FeatureFlags.livekitCall, ), body: SafeArea( child: WaterMarkBgView( text: '', path: logic.background.value, - backgroundColor: Styles.c_FFFFFF, + backgroundColor: Styles.c_F0F2F6, floatView: _groupCallHintView, bottomView: ChatInputBox( forceCloseToolboxSub: logic.forceCloseToolbox, diff --git a/mobile-next/lib/pages/contacts/contacts_logic.dart b/mobile-next/lib/pages/contacts/contacts_logic.dart index 77369a5..f717f42 100644 --- a/mobile-next/lib/pages/contacts/contacts_logic.dart +++ b/mobile-next/lib/pages/contacts/contacts_logic.dart @@ -46,7 +46,7 @@ class ContactsLogic extends GetxController void searchContacts() => AppNavigator.startGlobalSearch(); - void addContacts() => AppNavigator.startAddContactsMethod(); + void addContacts() => AppNavigator.startAddColleague(); @override Future? selectContacts( diff --git a/mobile-next/lib/pages/contacts/contacts_view.dart b/mobile-next/lib/pages/contacts/contacts_view.dart index 7013533..296723a 100644 --- a/mobile-next/lib/pages/contacts/contacts_view.dart +++ b/mobile-next/lib/pages/contacts/contacts_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:openim_common/openim_common.dart'; +import '../../company/brand/app_tokens.dart'; import 'contacts_logic.dart'; class ContactsPage extends StatelessWidget { @@ -13,79 +13,74 @@ class ContactsPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: TitleBar.contacts( - onClickAddContacts: logic.addContacts, + backgroundColor: AppColors.pageBg, + appBar: AppBar( + title: const Text('通讯录'), + actions: [ + IconButton( + onPressed: logic.addContacts, + icon: const Icon(Icons.person_add_alt, size: AppIconSize.md, color: AppColors.textPrimary), + ), + ], ), - backgroundColor: Styles.c_F8F9FA, body: Obx( - () => SingleChildScrollView( - child: Column( + () => Column( + children: [ + _entry( + icon: Icons.person_add_alt, + label: '新的同事', + count: logic.friendApplicationCount, + onTap: logic.newFriend, + ), + _entry( + icon: Icons.group_outlined, + label: '我的群聊', + count: logic.groupApplicationCount, + onTap: logic.myGroup, + ), + _entry( + icon: Icons.people_outline, + label: '同事', + onTap: logic.myFriend, + ), + ], + ), + ), + ); + } + + Widget _entry({ + required IconData icon, + required String label, + int count = 0, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: SizedBox( + height: 56, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppGap.x4), + child: Row( children: [ - _buildItemView( - assetsName: ImageRes.newFriend, - label: StrRes.newFriend, - count: logic.friendApplicationCount, - onTap: logic.newFriend, - ), - _buildItemView( - assetsName: ImageRes.newGroup, - label: StrRes.newGroupRequest, - count: logic.groupApplicationCount, - onTap: logic.newGroup, - ), - 10.verticalSpace, - _buildItemView( - assetsName: ImageRes.myFriend, - label: StrRes.myFriend, - onTap: logic.myFriend, - ), - _buildItemView( - assetsName: ImageRes.myGroup, - label: StrRes.myGroup, - onTap: logic.myGroup, + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primaryActive, + borderRadius: BorderRadius.circular(AppRadius.control), + ), + alignment: Alignment.center, + child: Icon(icon, size: AppIconSize.sm, color: AppColors.primary), ), + const SizedBox(width: AppGap.x3), + Text(label, style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + const Spacer(), + if (count > 0) UnreadCountView(count: count), ], ), ), ), ); } - - Widget _buildItemView({ - String? assetsName, - required String label, - Widget? icon, - int count = 0, - bool showRightArrow = true, - double? height, - Function()? onTap, - }) => - Ink( - color: Styles.c_FFFFFF, - child: InkWell( - onTap: onTap, - child: Container( - height: height ?? 60.h, - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: Row( - children: [ - if (null != assetsName) - assetsName.toImage - ..width = 42.w - ..height = 42.h, - if (null != icon) icon, - 12.horizontalSpace, - label.toText..style = Styles.ts_0C1C33_17sp, - const Spacer(), - if (count > 0) UnreadCountView(count: count), - 4.horizontalSpace, - if (showRightArrow) - ImageRes.rightArrow.toImage - ..width = 24.w - ..height = 24.h, - ], - ), - ), - ), - ); } diff --git a/mobile-next/lib/pages/contacts/friend_requests/friend_requests_logic.dart b/mobile-next/lib/pages/contacts/friend_requests/friend_requests_logic.dart index d95c906..f8d4268 100644 --- a/mobile-next/lib/pages/contacts/friend_requests/friend_requests_logic.dart +++ b/mobile-next/lib/pages/contacts/friend_requests/friend_requests_logic.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; import 'package:get/get.dart'; -import 'package:openim/routes/app_navigator.dart'; import 'package:openim_common/openim_common.dart'; import '../../../core/controller/im_controller.dart'; @@ -69,9 +68,27 @@ class FriendRequestsLogic extends GetxController { bool isISendRequest(FriendApplicationInfo info) => info.fromUserID == OpenIM.iMManager.userID; - void acceptFriendApplication(FriendApplicationInfo info) => AppNavigator.startProcessFriendRequests( - applicationInfo: info, + void acceptFriendApplication(FriendApplicationInfo info) async { + try { + await LoadingView.singleton.wrap( + asyncFunction: () => OpenIM.iMManager.friendshipManager.acceptFriendApplication(userID: info.fromUserID!), ); + IMViews.showToast(StrRes.addSuccessfully); + _getFriendRequestsList(); + } catch (_) { + IMViews.showToast(StrRes.addFailed); + } + } - void refuseFriendApplication(FriendApplicationInfo info) async {} + void refuseFriendApplication(FriendApplicationInfo info) async { + try { + await LoadingView.singleton.wrap( + asyncFunction: () => OpenIM.iMManager.friendshipManager.refuseFriendApplication(userID: info.fromUserID!), + ); + IMViews.showToast(StrRes.rejectSuccessfully); + _getFriendRequestsList(); + } catch (_) { + IMViews.showToast(StrRes.rejectFailed); + } + } } diff --git a/mobile-next/lib/pages/contacts/friend_requests/friend_requests_view.dart b/mobile-next/lib/pages/contacts/friend_requests/friend_requests_view.dart index 9e60ab8..e206fe7 100644 --- a/mobile-next/lib/pages/contacts/friend_requests/friend_requests_view.dart +++ b/mobile-next/lib/pages/contacts/friend_requests/friend_requests_view.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:openim_common/openim_common.dart'; +import '../../../company/brand/app_tokens.dart'; +import '../../../company/ui/state_views.dart'; import 'friend_requests_logic.dart'; class FriendRequestsPage extends StatelessWidget { @@ -14,75 +15,103 @@ class FriendRequestsPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: TitleBar.back(title: StrRes.newFriend), - backgroundColor: Styles.c_F8F9FA, - body: Obx(() => ListView.builder( - padding: EdgeInsets.only(top: 10.h), - itemCount: logic.applicationList.length, - itemBuilder: (_, index) => - _buildItemView(logic.applicationList[index]), - )), + appBar: AppBar(title: const Text('新的同事')), + backgroundColor: AppColors.pageBg, + body: Obx(() { + if (logic.applicationList.isEmpty) { + return const CompanyEmptyView( + icon: Icons.person_outline, + title: '暂无同事申请', + subtitle: '发出或收到的申请会显示在这里', + ); + } + return ListView.builder( + itemCount: logic.applicationList.length, + itemBuilder: (_, index) => _buildItemView(logic.applicationList[index]), + ); + }), ); } Widget _buildItemView(FriendApplicationInfo info) { final isISendRequest = info.fromUserID == OpenIM.iMManager.userID; - String? name = isISendRequest ? info.toNickname : info.fromNickname; - String? faceURL = isISendRequest ? info.toFaceURL : info.fromFaceURL; - String? reason = info.reqMsg; + final name = isISendRequest ? info.toNickname : info.fromNickname; + final faceURL = isISendRequest ? info.toFaceURL : info.fromFaceURL; + final waiting = info.isWaitingHandle; + final rejected = info.isRejected; + final agreed = info.isAgreed; return Container( - height: 68.h, - padding: EdgeInsets.symmetric(horizontal: 16.w), - decoration: BoxDecoration( - color: Styles.c_FFFFFF, - border: BorderDirectional( - bottom: BorderSide( - color: Styles.c_F8F9FA, - width: 1, - ), - ), + constraints: const BoxConstraints(minHeight: 72), + padding: const EdgeInsets.symmetric(horizontal: AppGap.x4, vertical: AppGap.x3), + decoration: const BoxDecoration( + color: AppColors.pageBg, + border: Border(bottom: BorderSide(color: AppColors.divider, width: 0.5)), ), child: Row( children: [ - AvatarView(url: faceURL, text: name), - 10.horizontalSpace, + AvatarView(url: faceURL, text: name, width: 48, height: 48), + const SizedBox(width: AppGap.x3), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ - (name ?? '').toText - ..style = Styles.ts_0C1C33_17sp - ..maxLines = 1 - ..overflow = TextOverflow.ellipsis, - 4.verticalSpace, - if (IMUtils.isNotNullEmptyStr(reason)) - (reason ?? '').toText - ..style = Styles.ts_8E9AB0_14sp - ..maxLines = 1 - ..overflow = TextOverflow.ellipsis, + Text( + name ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary), + ), + if (IMUtils.isNotNullEmptyStr(info.reqMsg)) + Text( + info.reqMsg ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary), + ), ], ), ), - if (/*info.isWaitingHandle && */ isISendRequest) - ImageRes.sendRequests.toImage - ..width = 20.w - ..height = 20.h, - if (info.isWaitingHandle && !isISendRequest) - Button( - text: StrRes.lookOver, - textStyle: Styles.ts_FFFFFF_14sp, - onTap: () => logic.acceptFriendApplication(info), - height: 28.h, - padding: EdgeInsets.symmetric(horizontal: 13.w), + const SizedBox(width: AppGap.x2), + if (waiting && isISendRequest) + const Text('等待对方通过', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + if (waiting && !isISendRequest) + SizedBox( + width: 132, + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => logic.refuseFriendApplication(info), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 32), + padding: EdgeInsets.zero, + side: const BorderSide(color: AppColors.textSecondary, width: 0.5), + foregroundColor: AppColors.textPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.control)), + ), + child: const Text('拒绝', style: TextStyle(fontSize: AppFont.sub)), + ), + ), + const SizedBox(width: AppGap.x2), + Expanded( + child: FilledButton( + onPressed: () => logic.acceptFriendApplication(info), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 32), + padding: EdgeInsets.zero, + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.control)), + ), + child: const Text('接受', style: TextStyle(fontSize: AppFont.sub, color: Colors.white)), + ), + ), + ], + ), ), - if (info.isWaitingHandle && isISendRequest) - StrRes.waitingForVerification.toText..style = Styles.ts_8E9AB0_14sp, - if (info.isRejected) - StrRes.rejected.toText..style = Styles.ts_8E9AB0_14sp, - if (info.isAgreed) - StrRes.approved.toText..style = Styles.ts_8E9AB0_14sp, + if (agreed) const Text('已添加', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + if (rejected) const Text('已拒绝', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), ], ), ); diff --git a/mobile-next/lib/pages/conversation/conversation_logic.dart b/mobile-next/lib/pages/conversation/conversation_logic.dart index 9d737f7..3125997 100644 --- a/mobile-next/lib/pages/conversation/conversation_logic.dart +++ b/mobile-next/lib/pages/conversation/conversation_logic.dart @@ -436,7 +436,7 @@ class ConversationLogic extends GetxController { scan() => AppNavigator.startScan(); - addFriend() => AppNavigator.startAddContactsBySearch(searchType: SearchType.user); + addFriend() => AppNavigator.startAddColleague(); createGroup() => AppNavigator.startCreateGroup(defaultCheckedList: [OpenIM.iMManager.userInfo]); diff --git a/mobile-next/lib/pages/conversation/conversation_view.dart b/mobile-next/lib/pages/conversation/conversation_view.dart index 5e5a900..7eac892 100644 --- a/mobile-next/lib/pages/conversation/conversation_view.dart +++ b/mobile-next/lib/pages/conversation/conversation_view.dart @@ -3,24 +3,24 @@ import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:get/get.dart'; -import 'package:openim/core/controller/im_controller.dart'; import 'package:openim_common/openim_common.dart'; import 'package:rotated_corner_decoration/rotated_corner_decoration.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:sprintf/sprintf.dart'; +import '../../company/brand/app_tokens.dart'; +import '../../company/ui/state_views.dart'; import 'conversation_logic.dart'; class ConversationPage extends StatelessWidget { final logic = Get.find(); - final im = Get.find(); ConversationPage({super.key}); @override Widget build(BuildContext context) { return Obx(() => Scaffold( - backgroundColor: Styles.c_F8F9FA, + backgroundColor: AppColors.pageBg, appBar: TitleBar.conversation( statusStr: logic.imSdkStatus, isFailed: logic.isFailedSdkStatus, @@ -31,37 +31,47 @@ class ConversationPage extends StatelessWidget { onCreateGroup: logic.createGroup, left: Expanded( flex: 2, - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - AvatarView( - width: 42.w, - height: 42.h, - text: im.userInfo.value.nickname, - url: im.userInfo.value.faceURL, + child: Align( + alignment: Alignment.centerLeft, + child: const Text( + '消息', + style: TextStyle( + fontSize: AppFont.title, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), - 10.horizontalSpace, - if (null != im.userInfo.value.nickname) - Flexible( - child: im.userInfo.value.nickname!.toText - ..style = Styles.ts_0C1C33_17sp - ..maxLines = 1 - ..overflow = TextOverflow.ellipsis, - ), - 10.horizontalSpace, - if (null != logic.imSdkStatus && (!logic.reInstall || logic.isFailedSdkStatus)) - Flexible( - child: SyncStatusView( - isFailed: logic.isFailedSdkStatus, - statusStr: logic.imSdkStatus!, - )), - ], + ), ), )), body: Column( children: [ + Padding( + padding: const EdgeInsets.fromLTRB(AppGap.x4, 0, AppGap.x4, AppGap.x2), + child: Container( + height: 36, + decoration: BoxDecoration( + color: AppColors.searchBg, + borderRadius: BorderRadius.circular(AppRadius.control), + ), + padding: const EdgeInsets.symmetric(horizontal: AppGap.x3), + alignment: Alignment.centerLeft, + child: const Row( + children: [ + Icon(Icons.search, size: AppIconSize.sm, color: AppColors.textSecondary), + SizedBox(width: AppGap.x2), + Text('搜索', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + ], + ), + ), + ), Expanded( - child: SlidableAutoCloseBehavior( + child: logic.list.isEmpty + ? const CompanyEmptyView( + icon: Icons.chat_bubble_outline, + title: '暂时没有会话', + subtitle: '去通讯录找个同事聊聊吧', + ) + : SlidableAutoCloseBehavior( child: ScrollablePositionedList.builder( itemScrollController: logic.itemScrollController, itemBuilder: (_, index) => _buildConversationItemView( @@ -117,7 +127,7 @@ class ConversationPage extends StatelessWidget { child: Stack( children: [ Container( - height: 68, + height: 72, padding: EdgeInsets.symmetric(horizontal: 16.w), child: Row( children: [ @@ -191,7 +201,7 @@ class ConversationPage extends StatelessWidget { ), if (logic.isPinned(info)) Container( - height: 68.h, + height: 72, margin: EdgeInsets.only(right: 6.w), foregroundDecoration: RotatedCornerDecoration.withColor( color: Styles.c_0089FF, diff --git a/mobile-next/lib/routes/app_navigator.dart b/mobile-next/lib/routes/app_navigator.dart index c9fb0e2..d2d22ad 100644 --- a/mobile-next/lib/routes/app_navigator.dart +++ b/mobile-next/lib/routes/app_navigator.dart @@ -74,7 +74,9 @@ class AppNavigator { static startFavoriteMange() => Get.toNamed(AppRoutes.favoriteManage); - static startAddContactsMethod() => Get.toNamed(AppRoutes.addContactsMethod); + static startAddColleague() => Get.toNamed(AppRoutes.addColleague); + + static startAddContactsMethod() => Get.toNamed(AppRoutes.addColleague); static startScan() => Permissions.camera(() => Get.to( () => const QrcodeView(), diff --git a/mobile-next/lib/routes/app_pages.dart b/mobile-next/lib/routes/app_pages.dart index 353c83f..8dcd309 100644 --- a/mobile-next/lib/routes/app_pages.dart +++ b/mobile-next/lib/routes/app_pages.dart @@ -74,8 +74,9 @@ import '../pages/global_search/global_search_binding.dart'; import '../pages/global_search/global_search_view.dart'; import '../pages/home/home_binding.dart'; import '../pages/home/home_view.dart'; -import '../pages/login/login_binding.dart'; -import '../pages/login/login_view.dart'; +import '../company/auth/company_login_logic.dart'; +import '../company/ui/add_colleague_page.dart'; +import '../company/ui/company_login_page.dart'; import '../pages/mine/about_us/about_us_binding.dart'; import '../pages/mine/about_us/about_us_view.dart'; import '../pages/mine/account_setup/account_setup_binding.dart'; @@ -132,8 +133,10 @@ class AppPages { ), _pageBuilder( name: AppRoutes.login, - page: () => LoginPage(), - binding: LoginBinding(), + page: () => const CompanyLoginPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => CompanyLoginLogic()); + }), ), _pageBuilder( name: AppRoutes.home, @@ -162,6 +165,10 @@ class AppPages { page: () => FavoriteManagePage(), binding: FavoriteManageBinding(), ), + _pageBuilder( + name: AppRoutes.addColleague, + page: () => const AddColleaguePage(), + ), _pageBuilder( name: AppRoutes.addContactsMethod, page: () => AddContactsMethodPage(), diff --git a/mobile-next/lib/routes/app_routes.dart b/mobile-next/lib/routes/app_routes.dart index 7c7d6a1..d6d79c0 100644 --- a/mobile-next/lib/routes/app_routes.dart +++ b/mobile-next/lib/routes/app_routes.dart @@ -9,6 +9,7 @@ abstract class AppRoutes { static const myQrcode = '/my_qrcode'; static const chatSetup = '/chat_setup'; static const favoriteManage = '/favorite_manage'; + static const addColleague = '/add_colleague'; static const addContactsMethod = '/add_contacts_method'; static const addContactsBySearch = '/add_contacts_by_search'; static const userProfilePanel = '/user_profile_panel'; diff --git a/mobile-next/openim_common/lib/src/widgets/chat/chat_bubble.dart b/mobile-next/openim_common/lib/src/widgets/chat/chat_bubble.dart index 93d7f9c..618938d 100644 --- a/mobile-next/openim_common/lib/src/widgets/chat/chat_bubble.dart +++ b/mobile-next/openim_common/lib/src/widgets/chat/chat_bubble.dart @@ -34,7 +34,7 @@ class ChatBubble extends StatelessWidget { alignment: alignment, decoration: BoxDecoration( color: - backgroundColor ?? (isISend ? Styles.c_CCE7FE : Styles.c_F4F5F7), + backgroundColor ?? (isISend ? Styles.c_CCE7FE : Styles.c_FFFFFF), borderRadius: borderRadius(isISend), ), child: child, diff --git a/mobile-next/openim_common/lib/src/widgets/chat/chat_item_view.dart b/mobile-next/openim_common/lib/src/widgets/chat/chat_item_view.dart index a7bbcf4..3915720 100644 --- a/mobile-next/openim_common/lib/src/widgets/chat/chat_item_view.dart +++ b/mobile-next/openim_common/lib/src/widgets/chat/chat_item_view.dart @@ -13,12 +13,7 @@ double pictureWidth = 120.w; double videoWidth = 120.w; double locationWidth = 220.w; -BorderRadius borderRadius(bool isISend) => BorderRadius.only( - topLeft: Radius.circular(isISend ? 6.r : 0), - topRight: Radius.circular(isISend ? 0 : 6.r), - bottomLeft: Radius.circular(6.r), - bottomRight: Radius.circular(6.r), - ); +BorderRadius borderRadius(bool isISend) => BorderRadius.circular(8.r); class MsgStreamEv { final String id;