import 'dart:ui'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/dial_prefix.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/pages/login/controller.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'; 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(); @override void didChangeDependencies() { super.didChangeDependencies(); _loginPageCtr.didChangeDependencies(context); } 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.primaryFixedDim, ), ), ), 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: ImageByteFormat.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: () => Get ..back() ..toNamed( '/webview', parameters: { 'url': 'https://passport.bilibili.com/h5-app/passport/login/findPassword', 'type': 'url', 'pageTitle': '忘记密码', }, ), ), ListTile( title: const Text( '找回密码(电脑版)', ), leading: const Icon(Icons.desktop_windows_outlined), subtitle: const Text( 'https://passport.bilibili.com/pc/passport/findPassword', ), dense: false, onTap: () => Get ..back() ..toNamed( '/webview', parameters: { 'url': 'https://passport.bilibili.com/pc/passport/findPassword', 'type': 'url', 'pageTitle': '忘记密码', 'uaType': 'pc', }, ), ), ], ), ); }, 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), ), ), ), ], ); } Widget loginBySmS(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: DecoratedBox( decoration: UnderlineTabIndicator( borderSide: BorderSide( color: theme.colorScheme.outline.withValues(alpha: 0.4), ), ), child: Row( children: [ const SizedBox(width: 12), Builder( builder: (context) { return PopupMenuButton( padding: EdgeInsets.zero, tooltip: '选择国际冠码,' '当前为${_loginPageCtr.selectedCountryCodeId.cname},' '+${_loginPageCtr.selectedCountryCodeId.countryId}', onSelected: (item) { _loginPageCtr.selectedCountryCodeId = item; (context as Element).markNeedsBuild(); }, initialValue: _loginPageCtr.selectedCountryCodeId, itemBuilder: (_) => Login.dialPrefix.map((item) { return PopupMenuItem( value: item, child: Row( children: [ Text(item.cname), const Spacer(), Text("+${item.countryId}"), ], ), ); }).toList(), child: Row( children: [ Icon( Icons.phone, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 12), Text( "+${_loginPageCtr.selectedCountryCodeId.countryId}", ), ], ), ); }, ), const SizedBox(width: 6), SizedBox( height: 24, child: VerticalDivider( color: theme.colorScheme.outline.withValues(alpha: 0.5), ), ), const SizedBox(width: 6), Expanded( child: TextField( controller: _loginPageCtr.telTextController, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], decoration: InputDecoration( border: InputBorder.none, labelText: '手机号', suffixIcon: IconButton( onPressed: _loginPageCtr.telTextController.clear, icon: const Icon(Icons.clear), ), ), ), ), ], ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: DecoratedBox( decoration: UnderlineTabIndicator( borderSide: BorderSide( color: theme.colorScheme.outline.withValues(alpha: 0.4), ), ), child: Row( children: [ Expanded( child: TextField( controller: _loginPageCtr.smsCodeTextController, decoration: const InputDecoration( prefixIcon: Icon(Icons.sms_outlined), border: InputBorder.none, labelText: '验证码', ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], ), ), Obx( () => TextButton.icon( onPressed: _loginPageCtr.smsSendCooldown > 0 ? null : _loginPageCtr.sendSmsCode, icon: const Icon(Icons.send), label: Text( _loginPageCtr.smsSendCooldown > 0 ? '等待${_loginPageCtr.smsSendCooldown}秒' : '获取验证码', ), ), ), ], ), ), ), const SizedBox(height: 20), OutlinedButton.icon( onPressed: _loginPageCtr.loginBySmsCode, icon: const Icon(Icons.login), label: const Text('登录'), ), const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text( '手机号仅用于 bilibili 官方发送验证码与登录接口,不予保存;\n' '本地仅存储登录凭证。\n' '请务必在 ${Constants.appName} 开源仓库等可信渠道下载安装。', textAlign: TextAlign.center, style: theme.textTheme.labelSmall!.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.4), ), ), ), ], ); } late EdgeInsets padding; @override Widget build(BuildContext context) { final theme = Theme.of(context); padding = MediaQuery.viewPaddingOf(context).copyWith(top: 0) + const EdgeInsets.only(bottom: 25); final isLandscape = !MediaQuery.sizeOf(context).isPortrait; return Scaffold( appBar: AppBar( leading: IconButton( tooltip: '关闭', icon: const Icon(Icons.close_outlined), onPressed: Get.back, ), title: Row( children: [ const Text('登录'), if (isLandscape) Expanded( child: Align( alignment: Alignment.centerRight, child: TabBar( isScrollable: true, dividerHeight: 0, tabs: const [ Tab( child: Row( mainAxisSize: MainAxisSize.min, children: [Icon(Icons.password), Text(' 密码')], ), ), Tab( child: Row( mainAxisSize: MainAxisSize.min, children: [Icon(Icons.sms_outlined), Text(' 短信')], ), ), Tab( child: Row( mainAxisSize: MainAxisSize.min, children: [Icon(Icons.qr_code), Text(' 扫码')], ), ), Tab( child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.cookie_outlined), Text(' Cookie'), ], ), ), ], controller: _loginPageCtr.tabController, ), ), ), ], ), bottom: !isLandscape ? TabBar( tabs: const [ Tab(icon: Icon(Icons.password), text: '密码'), Tab(icon: Icon(Icons.sms_outlined), text: '短信'), Tab(icon: Icon(Icons.qr_code), text: '扫码'), Tab(icon: Icon(Icons.cookie_outlined), text: 'Cookie'), ], controller: _loginPageCtr.tabController, ) : null, ), body: 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(loginBySmS(theme)), tabViewOuter(loginByQRCode(theme)), tabViewOuter(loginByCookie(theme)), ], ), ), ); } Widget tabViewOuter(Widget child) { return SingleChildScrollView( padding: padding, child: child.constraintWidth(), ); } }