import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/common/login_type.dart'; import 'package:PiliPlus/pages/login/controller.dart'; import 'package:PiliPlus/pages/webview/view.dart'; import 'package:PiliPlus/utils/extension/size_ext.dart'; import 'package:PiliPlus/utils/extension/widget_ext.dart'; import 'package:PiliPlus/utils/image_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pretty_qr_code/pretty_qr_code.dart'; import 'package:url_launcher/url_launcher.dart'; class LoginPage extends StatefulWidget { const LoginPage({super.key}); @override State createState() => _LoginPageState(); } class _LoginPageState extends State { final LoginPageController _loginPageCtr = Get.put(LoginPageController()); // 二维码生成时间 bool showPassword = false; GlobalKey globalKey = GlobalKey(); late EdgeInsets padding; @override void didChangeDependencies() { super.didChangeDependencies(); _loginPageCtr.didChangeDependencies(context); padding = MediaQuery.viewPaddingOf(context).copyWith(top: 0) + const EdgeInsets.only(bottom: 25); } Widget loginByQRCode(ThemeData theme) { return Column( children: [ const SizedBox(height: 20), const Text('使用 bilibili 官方 App 扫码登录'), const SizedBox(height: 20), Obx( () => Text( '剩余有效时间: ${_loginPageCtr.qrCodeLeftTime} 秒', style: TextStyle( fontFeatures: const [FontFeature.tabularFigures()], color: theme.colorScheme.primary, ), ), ), const SizedBox(height: 5), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton.icon( onPressed: _loginPageCtr.refreshQRCode, icon: const Icon(Icons.refresh), label: const Text('刷新二维码'), ), TextButton.icon( onPressed: () async { SmartDialog.showLoading(msg: '正在生成截图'); RenderRepaintBoundary boundary = globalKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; final image = await boundary.toImage(pixelRatio: 3); ByteData? byteData = await image.toByteData(format: .png); Uint8List pngBytes = byteData!.buffer.asUint8List(); SmartDialog.dismiss(); String picName = "${Constants.appName}_loginQRCode_${ImageUtils.time}"; ImageUtils.saveByteImg(bytes: pngBytes, fileName: picName); }, icon: const Icon(Icons.save), label: const Text('保存至相册'), ), if (kDebugMode || PlatformUtils.isMobile) TextButton.icon( onPressed: () => PageUtils.launchURL( 'bilibili://browser?url=${Uri.encodeComponent(_loginPageCtr.codeInfo.value.data.url)}', mode: LaunchMode.externalNonBrowserApplication, ), icon: const Icon(Icons.open_in_browser_outlined), label: const Text('其他应用打开'), ), ], ), RepaintBoundary( key: globalKey, child: Obx( () => switch (_loginPageCtr.codeInfo.value) { Loading() => const SizedBox( height: 200, width: 200, child: m3eLoading, ), Success(:final response) => Container( width: 200, height: 200, color: Colors.white, padding: const EdgeInsets.all(8), child: PrettyQrView.data( data: response.url, decoration: const PrettyQrDecoration( shape: PrettyQrSquaresSymbol( color: Colors.black87, ), ), ), ), Error(:final errMsg) => HttpError( isSliver: false, errMsg: errMsg, onReload: _loginPageCtr.refreshQRCode, ), }, ), ), const SizedBox(height: 10), Obx( () => Text( _loginPageCtr.statusQRCode.value, style: TextStyle(color: theme.colorScheme.secondaryFixedDim), ), ), Obx( () { final url = _loginPageCtr.codeInfo.value.dataOrNull?.url ?? ''; return GestureDetector( onTap: () => Utils.copyText( url, toastText: '已复制到剪贴板,可粘贴至已登录的app私信处发送,然后点击已发送的链接打开', ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 20, ), child: Text( url, style: theme.textTheme.labelSmall!.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.4), ), ), ), ); }, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text( '请务必在 ${Constants.appName} 开源仓库等可信渠道下载安装。', style: theme.textTheme.labelSmall!.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.4), ), ), ), ], ); } Widget loginByCookie(ThemeData theme) { return Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 20), const Text('使用Cookie登录'), const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text( '使用App端Api实现的功能将不可用', style: theme.textTheme.labelMedium!.copyWith( color: theme.colorScheme.primary, ), ), ), const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: TextField( minLines: 1, maxLines: 10, controller: _loginPageCtr.cookieTextController, inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r"\s"))], decoration: InputDecoration( prefixIcon: const Icon(Icons.cookie_outlined), border: const UnderlineInputBorder(), labelText: 'Cookie', suffixIcon: IconButton( onPressed: _loginPageCtr.cookieTextController.clear, icon: const Icon(Icons.clear), ), ), ), ), OutlinedButton.icon( onPressed: _loginPageCtr.loginByCookie, icon: const Icon(Icons.login), label: const Text('登录'), ), ], ); } Widget loginByPassword(ThemeData theme) { return Column( children: [ const SizedBox(height: 20), const Text('使用账号密码登录'), const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: TextField( controller: _loginPageCtr.usernameTextController, inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r"\s"))], decoration: InputDecoration( prefixIcon: const Icon(Icons.account_box), border: const UnderlineInputBorder(), labelText: '账号', hintText: '邮箱/手机号', suffixIcon: IconButton( onPressed: _loginPageCtr.usernameTextController.clear, icon: const Icon(Icons.clear), ), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: TextField( obscureText: !showPassword, keyboardType: TextInputType.visiblePassword, inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r"\s"))], controller: _loginPageCtr.passwordTextController, autofillHints: const [AutofillHints.password], decoration: InputDecoration( prefixIcon: const Icon(Icons.password), border: const UnderlineInputBorder(), labelText: '密码', suffixIcon: IconButton( onPressed: _loginPageCtr.passwordTextController.clear, icon: const Icon(Icons.clear), ), ), ), ), Row( children: [ const SizedBox(width: 10), Checkbox( value: showPassword, onChanged: (value) => setState(() => showPassword = value!), ), const Text('显示密码'), const Spacer(), TextButton( onPressed: () { //https://passport.bilibili.com/h5-app/passport/login/findPassword //https://passport.bilibili.com/passport/findPassword showDialog( context: context, builder: (context) => SimpleDialog( clipBehavior: Clip.hardEdge, title: const Text('忘记密码?'), contentPadding: const EdgeInsets.fromLTRB( 0.0, 2.0, 0.0, 16.0, ), children: [ const Padding( padding: EdgeInsets.fromLTRB(25, 0, 25, 10), child: Text("试试扫码、手机号登录,或选择"), ), ListTile( title: const Text( '找回密码(手机版)', ), leading: const Icon(Icons.smartphone_outlined), subtitle: const Text( 'https://passport.bilibili.com/h5-app/passport/login/findPassword', ), dense: false, onTap: () => WebViewPage.toWebView( 'https://passport.bilibili.com/h5-app/passport/login/findPassword', getBack: true, ), ), ListTile( title: const Text( '找回密码(电脑版)', ), leading: const Icon(Icons.desktop_windows_outlined), subtitle: const Text( 'https://passport.bilibili.com/pc/passport/findPassword', ), dense: false, onTap: () => WebViewPage.toWebView( 'https://passport.bilibili.com/pc/passport/findPassword', uaType: 'pc', getBack: true, ), ), ], ), ); }, child: const Text('忘记密码'), ), const SizedBox(width: 20), ], ), OutlinedButton.icon( onPressed: _loginPageCtr.loginByPassword, icon: const Icon(Icons.login), label: const Text('登录'), ), const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text( '根据 bilibili 官方登录接口规范,密码将在本地加盐、加密后传输。\n' '盐与公钥均由官方提供;以 RSA/ECB/PKCS1Padding 方式加密。\n' '账号密码仅用于该登录接口,不予保存;本地仅存储登录凭证。\n' '请务必在 ${Constants.appName} 开源仓库等可信渠道下载安装。', textAlign: TextAlign.center, style: theme.textTheme.labelSmall!.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.4), ), ), ), ], ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isPortrait = MediaQuery.sizeOf(context).isPortrait; return Material( child: Column( children: [ AppBar( leading: IconButton( tooltip: '关闭', icon: const Icon(Icons.close_outlined), onPressed: Get.back, ), title: Row( children: [ const Text('登录'), if (!isPortrait) Expanded( child: Align( alignment: Alignment.centerRight, child: TabBar( isScrollable: true, dividerHeight: 0, tabs: LoginType.values .map( (e) => Tab( child: Row( mainAxisSize: .min, children: [e.icon, Text(' ${e.label}')], ), ), ) .toList(), controller: _loginPageCtr.tabController, ), ), ), ], ), ), if (isPortrait) TabBar( tabs: LoginType.values .map((e) => Tab(icon: e.icon, text: e.label)) .toList(), controller: _loginPageCtr.tabController, ), Expanded( child: NotificationListener( onNotification: (notification) { if (notification.metrics.axis == Axis.horizontal) { FocusScope.of(context).unfocus(); } return false; }, child: tabBarView( controller: _loginPageCtr.tabController, children: [ tabViewOuter(loginByPassword(theme)), tabViewOuter(loginByQRCode(theme)), tabViewOuter(loginByCookie(theme)), ], ), ), ), ], ), ); } Widget tabViewOuter(Widget child) { return SingleChildScrollView( padding: padding, child: child.constraintWidth(), ); } }