diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 796c3f9..c61df29 100755 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ + + + + + + + + @@ -31,4 +39,10 @@ android:name="flutterEmbedding" android:value="2" /> + + + + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index fb0e56f..86d19d6 100755 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,9 +1,19 @@ PODS: - Flutter (1.0.0) + - flutter_inappwebview (0.0.1): + - Flutter + - flutter_inappwebview/Core (= 0.0.1) + - OrderedSet (~> 5.0) + - flutter_inappwebview/Core (0.0.1): + - Flutter + - OrderedSet (~> 5.0) - flutter_web_browser (0.17.1): - Flutter + - OrderedSet (5.0.0) - package_info_plus (0.4.5): - Flutter + - shared_preferences_ios (0.0.1): + - Flutter - video_player_avfoundation (0.0.1): - Flutter - wakelock (0.0.1): @@ -13,19 +23,29 @@ PODS: DEPENDENCIES: - Flutter (from `Flutter`) + - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) - flutter_web_browser (from `.symlinks/plugins/flutter_web_browser/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) - wakelock (from `.symlinks/plugins/wakelock/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) +SPEC REPOS: + trunk: + - OrderedSet + EXTERNAL SOURCES: Flutter: :path: Flutter + flutter_inappwebview: + :path: ".symlinks/plugins/flutter_inappwebview/ios" flutter_web_browser: :path: ".symlinks/plugins/flutter_web_browser/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + shared_preferences_ios: + :path: ".symlinks/plugins/shared_preferences_ios/ios" video_player_avfoundation: :path: ".symlinks/plugins/video_player_avfoundation/ios" wakelock: @@ -35,8 +55,11 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721 flutter_web_browser: 7bccaafbb0c5b8862afe7bcd158f15557109f61f + OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e + shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f diff --git a/lib/api/base_api.dart b/lib/api/base_api.dart index fa82f31..dcfb4c6 100644 --- a/lib/api/base_api.dart +++ b/lib/api/base_api.dart @@ -1,9 +1,13 @@ import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio_http2_adapter/dio_http2_adapter.dart'; import 'package:flutter/foundation.dart'; import 'package:isekai_wiki/global.dart'; import 'package:isekai_wiki/utils/api_utils.dart'; -import 'package:http/http.dart' as http; class HttpResponseCodeError extends Error { int? statusCode; @@ -17,25 +21,68 @@ class HttpResponseCodeError extends Error { } class BaseApi { - static Future> _getHeaders() async { - Map headers = {}; + static Dio? _dioInstance; + static CookieJar? cookieJar; + + static Dio createClient() { + var dio = Dio(); + + return dio; + } + + static Dio getClient() { + _dioInstance ??= createClient(); if (!kIsWeb) { - headers["X-IsekaiWikiApp-Version"] = Global.packageInfo?.version ?? "unknow"; - headers["User-Agent"] = await ApiUtils.getUserAgent(); + // HTTP2 + _dioInstance!.httpClientAdapter = Http2Adapter( + ConnectionManager( + idleTimeout: 10000, + ), + ); + + // Cookie + cookieJar = PersistCookieJar(); + _dioInstance!.interceptors.add(CookieManager(cookieJar!)); + + // 缓存 + final cacheOptions = CacheOptions( + store: MemCacheStore(), + policy: CachePolicy.request, + maxStale: const Duration(days: 7), + priority: CachePriority.normal, + cipher: null, + keyBuilder: CacheOptions.defaultCacheKeyBuilder, + allowPostMethod: false, + ); + _dioInstance!.interceptors + .add(DioCacheInterceptor(options: cacheOptions)); + + // 自定义Header + _dioInstance!.interceptors.add( + InterceptorsWrapper(onRequest: (options, handler) { + options.headers["X-IsekaiWikiApp-Version"] = + Global.packageInfo?.version ?? "unknow"; + options.headers["User-Agent"] = ""; + return handler.next(options); + }), + ); } - return headers; + return _dioInstance!; } static Future get(Uri uri, {Map? search}) async { - var res = await http.get(uri, headers: await _getHeaders()); + var res = await getClient().get( + uri.toString(), + options: Options(responseType: ResponseType.plain), + ); if (res.statusCode != 200) { throw HttpResponseCodeError(res.statusCode); } - return res.body; + return res.data ?? ""; } static Future getJson(Uri uri) async { diff --git a/lib/api/mw/user.dart b/lib/api/mw/user.dart new file mode 100644 index 0000000..4a34d89 --- /dev/null +++ b/lib/api/mw/user.dart @@ -0,0 +1,73 @@ +import 'package:isekai_wiki/api/response/userinfo.dart'; + +import '../response/mugenapp.dart'; +import 'mw_api.dart'; + +class MWApiUser { + static Future> startAuth() async { + var query = { + "method": "startauth", + }; + + var mwRes = await MWApi.get("mugenapp", query: query); + + if (!mwRes.ok) { + return MWResponse(errorList: mwRes.errorList); + } + if (mwRes.data != null) { + var authRes = MugenAppStartAuthResponse.fromJson(mwRes.data!); + + return MWResponse( + ok: true, data: authRes.startauth, continueInfo: mwRes.continueInfo); + } else { + return MWResponse( + errorList: [MWError(code: 'response_data_empty', info: '加载的数据为空')]); + } + } + + static Future> attemptAuth( + String loginRequestKey) async { + var query = { + "method": "attemptauth", + "requestkey": loginRequestKey, + }; + + var mwRes = await MWApi.get("mugenapp", query: query); + + if (!mwRes.ok) { + return MWResponse(errorList: mwRes.errorList); + } + if (mwRes.data != null) { + var authRes = MugenAppAttemptAuthResponse.fromJson(mwRes.data!); + + return MWResponse( + ok: true, + data: authRes.attemptauth, + continueInfo: mwRes.continueInfo); + } else { + return MWResponse( + errorList: [MWError(code: 'response_data_empty', info: '加载的数据为空')]); + } + } + + static Future> getCurrentUserInfo() async { + var query = { + "meta": "userinfo|useravatar", + "uiprop": "blockinfo|groups|rights|options|email|realname|latestcontrib", + }; + + var mwRes = await MWApi.get("query", query: query); + + if (!mwRes.ok) { + return MWResponse(errorList: mwRes.errorList); + } + if (mwRes.data != null) { + var userInfoRes = MetaUserInfoResponse.fromJson(mwRes.data!); + + return MWResponse(ok: true, data: userInfoRes); + } else { + return MWResponse( + errorList: [MWError(code: 'response_data_empty', info: '加载的数据为空')]); + } + } +} diff --git a/lib/api/response/mugenapp.dart b/lib/api/response/mugenapp.dart new file mode 100644 index 0000000..b32d057 --- /dev/null +++ b/lib/api/response/mugenapp.dart @@ -0,0 +1,65 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'mugenapp.g.dart'; + +@JsonSerializable() +class MugenAppStartAuthInfo { + String loginUrl; + String loginRequestKey; + int ttl; + + MugenAppStartAuthInfo({ + required this.loginUrl, + required this.loginRequestKey, + this.ttl = 0, + }); + + factory MugenAppStartAuthInfo.fromJson(Map json) => + _$MugenAppStartAuthInfoFromJson(json); + + Map toJson() => _$MugenAppStartAuthInfoToJson(this); +} + +@JsonSerializable() +class MugenAppStartAuthResponse { + MugenAppStartAuthInfo startauth; + + MugenAppStartAuthResponse({required this.startauth}); + + factory MugenAppStartAuthResponse.fromJson(Map json) => + _$MugenAppStartAuthResponseFromJson(json); + + Map toJson() => _$MugenAppStartAuthResponseToJson(this); +} + +@JsonSerializable() +class MugenAppAttemptAuthInfo { + String status; + int? userid; + String? username; + + MugenAppAttemptAuthInfo({ + required this.status, + this.userid, + this.username, + }); + + factory MugenAppAttemptAuthInfo.fromJson(Map json) => + _$MugenAppAttemptAuthInfoFromJson(json); + + Map toJson() => _$MugenAppAttemptAuthInfoToJson(this); +} + +@JsonSerializable() +class MugenAppAttemptAuthResponse { + MugenAppAttemptAuthInfo attemptauth; + + MugenAppAttemptAuthResponse({ + required this.attemptauth, + }); + + factory MugenAppAttemptAuthResponse.fromJson(Map json) => + _$MugenAppAttemptAuthResponseFromJson(json); + + Map toJson() => _$MugenAppAttemptAuthResponseToJson(this); +} diff --git a/lib/api/response/mugenapp.g.dart b/lib/api/response/mugenapp.g.dart new file mode 100644 index 0000000..e15464a --- /dev/null +++ b/lib/api/response/mugenapp.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mugenapp.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MugenAppStartAuthInfo _$MugenAppStartAuthInfoFromJson( + Map json) => + MugenAppStartAuthInfo( + loginUrl: json['loginUrl'] as String, + loginRequestKey: json['loginRequestKey'] as String, + ttl: json['ttl'] as int? ?? 0, + ); + +Map _$MugenAppStartAuthInfoToJson( + MugenAppStartAuthInfo instance) => + { + 'loginUrl': instance.loginUrl, + 'loginRequestKey': instance.loginRequestKey, + 'ttl': instance.ttl, + }; + +MugenAppStartAuthResponse _$MugenAppStartAuthResponseFromJson( + Map json) => + MugenAppStartAuthResponse( + startauth: MugenAppStartAuthInfo.fromJson( + json['startauth'] as Map), + ); + +Map _$MugenAppStartAuthResponseToJson( + MugenAppStartAuthResponse instance) => + { + 'startauth': instance.startauth, + }; + +MugenAppAttemptAuthInfo _$MugenAppAttemptAuthInfoFromJson( + Map json) => + MugenAppAttemptAuthInfo( + status: json['status'] as String, + userid: json['userid'] as int?, + username: json['username'] as String?, + ); + +Map _$MugenAppAttemptAuthInfoToJson( + MugenAppAttemptAuthInfo instance) => + { + 'status': instance.status, + 'userid': instance.userid, + 'username': instance.username, + }; + +MugenAppAttemptAuthResponse _$MugenAppAttemptAuthResponseFromJson( + Map json) => + MugenAppAttemptAuthResponse( + attemptauth: MugenAppAttemptAuthInfo.fromJson( + json['attemptauth'] as Map), + ); + +Map _$MugenAppAttemptAuthResponseToJson( + MugenAppAttemptAuthResponse instance) => + { + 'attemptauth': instance.attemptauth, + }; diff --git a/lib/api/response/userinfo.dart b/lib/api/response/userinfo.dart new file mode 100644 index 0000000..f390e50 --- /dev/null +++ b/lib/api/response/userinfo.dart @@ -0,0 +1,101 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'userinfo.g.dart'; + +@JsonSerializable() +class UserGroupMembership { + String group; + String expiry; + + UserGroupMembership({ + required this.group, + required this.expiry, + }); + + factory UserGroupMembership.fromJson(Map json) => + _$UserGroupMembershipFromJson(json); + + Map toJson() => _$UserGroupMembershipToJson(this); +} + +@JsonSerializable() +class UserAcceptLang { + double q; + + @JsonKey(name: '*') + String langCode; + + UserAcceptLang({ + required this.q, + required this.langCode, + }); + + factory UserAcceptLang.fromJson(Map json) => + _$UserAcceptLangFromJson(json); + + Map toJson() => _$UserAcceptLangToJson(this); +} + +@JsonSerializable() +class MetaUserInfo { + int id; + String name; + List? groups; + List? groupmemberships; + List? implicitgroups; + List? rights; + Map>? changeablegroups; + Map? options; + int? editcount; + String? realname; + String? email; + DateTime? emailauthenticated; + DateTime? registrationdate; + List? acceptlang; + int? unreadcount; + Map? centralids; + Map? attachedlocal; + DateTime? latestcontrib; + + MetaUserInfo({ + required this.id, + required this.name, + this.groups, + this.groupmemberships, + this.implicitgroups, + this.rights, + this.changeablegroups, + this.options, + this.editcount, + this.realname, + this.email, + this.emailauthenticated, + this.registrationdate, + this.acceptlang, + this.unreadcount, + this.centralids, + this.attachedlocal, + this.latestcontrib, + }); + + factory MetaUserInfo.fromJson(Map json) => + _$MetaUserInfoFromJson(json); + + Map toJson() => _$MetaUserInfoToJson(this); +} + +@JsonSerializable() +class MetaUserInfoResponse { + MetaUserInfo userinfo; + Map? useravatar; + + MetaUserInfoResponse({ + required this.userinfo, + this.useravatar, + }); + + factory MetaUserInfoResponse.fromJson(Map json) => + _$MetaUserInfoResponseFromJson(json); + + Map toJson() => _$MetaUserInfoResponseToJson(this); +} diff --git a/lib/api/response/userinfo.g.dart b/lib/api/response/userinfo.g.dart new file mode 100644 index 0000000..8cf69f6 --- /dev/null +++ b/lib/api/response/userinfo.g.dart @@ -0,0 +1,114 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'userinfo.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UserGroupMembership _$UserGroupMembershipFromJson(Map json) => + UserGroupMembership( + group: json['group'] as String, + expiry: json['expiry'] as String, + ); + +Map _$UserGroupMembershipToJson( + UserGroupMembership instance) => + { + 'group': instance.group, + 'expiry': instance.expiry, + }; + +UserAcceptLang _$UserAcceptLangFromJson(Map json) => + UserAcceptLang( + q: (json['q'] as num).toDouble(), + langCode: json['*'] as String, + ); + +Map _$UserAcceptLangToJson(UserAcceptLang instance) => + { + 'q': instance.q, + '*': instance.langCode, + }; + +MetaUserInfo _$MetaUserInfoFromJson(Map json) => MetaUserInfo( + id: json['id'] as int, + name: json['name'] as String, + groups: + (json['groups'] as List?)?.map((e) => e as String).toList(), + groupmemberships: (json['groupmemberships'] as List?) + ?.map((e) => UserGroupMembership.fromJson(e as Map)) + .toList(), + implicitgroups: (json['implicitgroups'] as List?) + ?.map((e) => e as String) + .toList(), + rights: + (json['rights'] as List?)?.map((e) => e as String).toList(), + changeablegroups: + (json['changeablegroups'] as Map?)?.map( + (k, e) => + MapEntry(k, (e as List).map((e) => e as String).toList()), + ), + options: json['options'] as Map?, + editcount: json['editcount'] as int?, + realname: json['realname'] as String?, + email: json['email'] as String?, + emailauthenticated: json['emailauthenticated'] == null + ? null + : DateTime.parse(json['emailauthenticated'] as String), + registrationdate: json['registrationdate'] == null + ? null + : DateTime.parse(json['registrationdate'] as String), + acceptlang: (json['acceptlang'] as List?) + ?.map((e) => UserAcceptLang.fromJson(e as Map)) + .toList(), + unreadcount: json['unreadcount'] as int?, + centralids: (json['centralids'] as Map?)?.map( + (k, e) => MapEntry(k, e as int), + ), + attachedlocal: (json['attachedlocal'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + latestcontrib: json['latestcontrib'] == null + ? null + : DateTime.parse(json['latestcontrib'] as String), + ); + +Map _$MetaUserInfoToJson(MetaUserInfo instance) => + { + 'id': instance.id, + 'name': instance.name, + 'groups': instance.groups, + 'groupmemberships': instance.groupmemberships, + 'implicitgroups': instance.implicitgroups, + 'rights': instance.rights, + 'changeablegroups': instance.changeablegroups, + 'options': instance.options, + 'editcount': instance.editcount, + 'realname': instance.realname, + 'email': instance.email, + 'emailauthenticated': instance.emailauthenticated?.toIso8601String(), + 'registrationdate': instance.registrationdate?.toIso8601String(), + 'acceptlang': instance.acceptlang, + 'unreadcount': instance.unreadcount, + 'centralids': instance.centralids, + 'attachedlocal': instance.attachedlocal, + 'latestcontrib': instance.latestcontrib?.toIso8601String(), + }; + +MetaUserInfoResponse _$MetaUserInfoResponseFromJson( + Map json) => + MetaUserInfoResponse( + userinfo: MetaUserInfo.fromJson(json['userinfo'] as Map), + useravatar: (json['useravatar'] as Map?)?.map( + (k, e) => MapEntry(int.parse(k), e as String), + ), + ); + +Map _$MetaUserInfoResponseToJson( + MetaUserInfoResponse instance) => + { + 'userinfo': instance.userinfo, + 'useravatar': + instance.useravatar?.map((k, e) => MapEntry(k.toString(), e)), + }; diff --git a/lib/components/isekai_nav_bar.dart b/lib/components/isekai_nav_bar.dart index d9c6376..63f8533 100755 --- a/lib/components/isekai_nav_bar.dart +++ b/lib/components/isekai_nav_bar.dart @@ -9,6 +9,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:get/get.dart'; /// Standard iOS navigation bar height without the status bar. /// @@ -420,7 +421,8 @@ class IsekaiNavigationBar extends StatefulWidget @override Size get preferredSize { - return const Size.fromHeight(_kNavBarPersistentHeight); + double scaleFactor = MediaQuery.of(Get.context!).textScaleFactor; + return Size.fromHeight(_kNavBarPersistentHeight * scaleFactor); } @override @@ -727,6 +729,7 @@ class _IsekaiSliverNavigationBarState extends State { large: true, ); + double scaleFactor = MediaQuery.of(context).textScaleFactor; return SliverPersistentHeader( pinned: true, // iOS navigation bars are always pinned. delegate: _LargeTitleNavigationBarSliverDelegate( @@ -742,8 +745,8 @@ class _IsekaiSliverNavigationBarState extends State { actionsForegroundColor: CupertinoTheme.of(context).primaryColor, transitionBetweenRoutes: widget.transitionBetweenRoutes, heroTag: widget.heroTag, - persistentHeight: - _kNavBarPersistentHeight + MediaQuery.of(context).padding.top, + persistentHeight: (_kNavBarPersistentHeight * scaleFactor) + + MediaQuery.of(context).padding.top, alwaysShowMiddle: widget.middle != null, stretchConfiguration: widget.stretch ? OverScrollHeaderStretchConfiguration() : null, @@ -789,7 +792,10 @@ class _LargeTitleNavigationBarSliverDelegate double get minExtent => persistentHeight; @override - double get maxExtent => persistentHeight + _kNavBarLargeTitleHeightExtension; + double get maxExtent => + persistentHeight + + (_kNavBarLargeTitleHeightExtension * + MediaQuery.of(Get.context!).textScaleFactor); @override OverScrollHeaderStretchConfiguration? stretchConfiguration; @@ -985,8 +991,11 @@ class _PersistentNavigationBar extends StatelessWidget { ); } + double scaleFactor = MediaQuery.of(context).textScaleFactor; + return SizedBox( - height: _kNavBarPersistentHeight + MediaQuery.of(context).padding.top, + height: (_kNavBarPersistentHeight * scaleFactor) + + MediaQuery.of(context).padding.top, child: SafeArea( bottom: false, child: paddedToolbar, diff --git a/lib/models/lifecycle.dart b/lib/models/lifecycle.dart index 360cd7f..114daf6 100644 --- a/lib/models/lifecycle.dart +++ b/lib/models/lifecycle.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; import 'package:isekai_wiki/models/user.dart'; @@ -17,11 +18,8 @@ class LifeCycleController extends SuperController { @override void onResumed() { debugPrint("onResume"); - try { - var uc = Get.find(); - uc.attemptFinishAuth().catchError((err) { - err.printError(info: 'attemptFinishAuth'); - }); - } catch (_) {} + var uc = Get.find(); + + uc.attemptFinishAuth(); } } diff --git a/lib/models/user.dart b/lib/models/user.dart index c225d00..7bf10b6 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -1,8 +1,12 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_web_browser/flutter_web_browser.dart'; import 'package:get/get.dart'; +import 'package:isekai_wiki/api/mw/user.dart'; import 'package:isekai_wiki/global.dart'; +import 'package:isekai_wiki/utils/dialog.dart'; import 'package:json_annotation/json_annotation.dart'; part 'user.g.dart'; @@ -12,11 +16,17 @@ class UserInfo { int userId; String userName; String? nickName; - String? avatarUrl; + Map? avatarUrlSet; - UserInfo({required this.userId, required this.userName, this.nickName, this.avatarUrl}); + UserInfo({ + required this.userId, + required this.userName, + this.nickName, + this.avatarUrlSet, + }); - factory UserInfo.fromJson(Map json) => _$UserInfoFromJson(json); + factory UserInfo.fromJson(Map json) => + _$UserInfoFromJson(json); Map toJson() => _$UserInfoToJson(this); } @@ -24,13 +34,15 @@ class UserInfo { class UserController extends GetxController { bool _ignoreSave = false; + var authProcessing = false.obs; + var loginRequestToken = "".obs; var userId = 0.obs; var userName = "".obs; var nickName = "".obs; - var avatarUrl = "".obs; + var avatarUrlSet = RxMap({}); bool get isLoggedIn { return userId.value > 0; @@ -45,6 +57,7 @@ class UserController extends GetxController { super.onInit(); loadFromStorage(); + updateProfile(); ever(loginRequestToken, (String token) { saveToStorage(); @@ -52,7 +65,35 @@ class UserController extends GetxController { } /// 更新用户资料,并检测登录状态 - Future updateProfile() async {} + Future updateProfile() async { + var userInfoMWRes = await MWApiUser.getCurrentUserInfo(); + if (!userInfoMWRes.ok) { + if (kDebugMode) { + print("Cannot update profile of current user"); + print(userInfoMWRes.errorList); + } + } + + var userInfoRes = userInfoMWRes.data!; + nickName.value = userInfoRes.userinfo.realname ?? ""; + if (userInfoRes.useravatar != null) { + avatarUrlSet.value = userInfoRes.useravatar!; + } + } + + String? getAvatar(int size) { + if (avatarUrlSet.isEmpty) { + return null; + } + + for (var imgSize in avatarUrlSet.keys) { + if (size < imgSize) { + return avatarUrlSet[size]; + } + } + + return avatarUrlSet.values.last; + } /// 从本地存储读取 void loadFromStorage() { @@ -70,7 +111,7 @@ class UserController extends GetxController { userId.value = userInfo.userId; userName.value = userInfo.userName; nickName.value = userInfo.nickName ?? ""; - avatarUrl.value = userInfo.avatarUrl ?? ""; + avatarUrlSet.value = userInfo.avatarUrlSet ?? {}; _ignoreSave = false; var savedLoginRequestToken = prefs.getString("loginRequestToken"); @@ -98,7 +139,7 @@ class UserController extends GetxController { userId: userId.value, userName: userName.value, nickName: nickName.isNotEmpty ? nickName.value : null, - avatarUrl: avatarUrl.isNotEmpty ? avatarUrl.value : null, + avatarUrlSet: avatarUrlSet.isNotEmpty ? avatarUrlSet : null, ); var userInfoJson = jsonEncode(userInfo.toJson()); @@ -114,12 +155,72 @@ class UserController extends GetxController { /// 开始登录流程 /// 从服务器获取loginRequestToken - Future startAuthFlow() async { - return false; + Future startAuthFlow() async { + authProcessing.value = true; + + var startAuthRes = await MWApiUser.startAuth(); + if (!startAuthRes.ok) { + authProcessing.value = false; + alert(Get.overlayContext!, startAuthRes.errorList?[0].info ?? "未知错误", + title: "错误"); + return; + } + + var startAuthInfo = startAuthRes.data!; + loginRequestToken.value = startAuthInfo.loginRequestKey; + + await FlutterWebBrowser.openWebPage( + url: startAuthInfo.loginUrl, + customTabsOptions: const CustomTabsOptions( + defaultColorSchemeParams: CustomTabsColorSchemeParams( + toolbarColor: Color.fromRGBO(33, 37, 41, 1), + ), + shareState: CustomTabsShareState.off, + showTitle: true, + ), + safariVCOptions: const SafariViewControllerOptions( + barCollapsingEnabled: true, + )); } // 尝试通过登录请求Token完成登录 Future attemptFinishAuth() async { - if (loginRequestToken.isEmpty) return; + if (loginRequestToken.isEmpty) { + authProcessing.value = false; + return; + } + + var attemptAuthRes = await MWApiUser.attemptAuth(loginRequestToken.value); + + if (!attemptAuthRes.ok) { + authProcessing.value = false; + alert(Get.overlayContext!, attemptAuthRes.errorList?[0].info ?? "未知错误", + title: "错误"); + return; + } + + var attemptAuthInfo = attemptAuthRes.data!; + if (attemptAuthInfo.status == "pending") { + authProcessing.value = false; + loginRequestToken.value = ""; + + alert(Get.overlayContext!, "已取消登录", title: "提示"); + + return; + } else if (attemptAuthInfo.status == "success") { + userId.value = attemptAuthInfo.userid!; + userName.value = attemptAuthInfo.username!; + + try { + await updateProfile(); + } catch (err) { + err.printError(info: 'Cannot update profile after auth'); + } + + authProcessing.value = false; + saveToStorage(); + } } + + Future logout() async {} } diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index cf73e13..20c3284 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -10,12 +10,15 @@ UserInfo _$UserInfoFromJson(Map json) => UserInfo( userId: json['userId'] as int, userName: json['userName'] as String, nickName: json['nickName'] as String?, - avatarUrl: json['avatarUrl'] as String?, + avatarUrlSet: (json['avatarUrlSet'] as Map?)?.map( + (k, e) => MapEntry(int.parse(k), e as String), + ), ); Map _$UserInfoToJson(UserInfo instance) => { 'userId': instance.userId, 'userName': instance.userName, 'nickName': instance.nickName, - 'avatarUrl': instance.avatarUrl, + 'avatarUrlSet': + instance.avatarUrlSet?.map((k, e) => MapEntry(k.toString(), e)), }; diff --git a/lib/pages/about.dart b/lib/pages/about.dart index 392200b..c1bcc9b 100755 --- a/lib/pages/about.dart +++ b/lib/pages/about.dart @@ -33,7 +33,10 @@ class AboutPage extends StatelessWidget { var c = Get.put(AboutPageController()); return IsekaiPageScaffold( - navigationBar: const IsekaiNavigationBar(middle: Text('关于'), previousPageTitle: "设置"), + navigationBar: const IsekaiNavigationBar( + middle: Text('关于'), + previousPageTitle: "我的", + ), child: ListView( children: [ const SizedBox(height: 18), @@ -43,7 +46,8 @@ class AboutPage extends StatelessWidget { top: false, bottom: false, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 16), child: Column( children: const [ Text("异世界百科APP", style: Styles.articleTitle), @@ -59,7 +63,8 @@ class AboutPage extends StatelessWidget { backgroundColor: Styles.themePageBackgroundColor, children: [ CupertinoListTile.notched( - title: const Text('异世界百科', style: TextStyle(color: Styles.linkColor)), + title: const Text('异世界百科', + style: TextStyle(color: Styles.linkColor)), leading: const DummyIcon( color: CupertinoColors.systemBlue, icon: CupertinoIcons.globe, diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 176bf54..03994d6 100755 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:isekai_wiki/components/isekai_nav_bar.dart'; import 'package:isekai_wiki/components/recent_page_list.dart'; import 'package:isekai_wiki/models/user.dart'; import 'package:isekai_wiki/pages/tab_page.dart'; @@ -15,7 +16,10 @@ import '../styles.dart'; enum HomeTabs { newest, followed } -class HomeController extends GetxController with GetSingleTickerProviderStateMixin { +class HomeController extends GetxController + with GetSingleTickerProviderStateMixin { + double _navSearchButtonOffset = 90; + var showNavSearchButton = false.obs; var isScrolling = false.obs; @@ -32,10 +36,15 @@ class HomeController extends GetxController with GetSingleTickerProviderStateMix void onInit() { tabController = TabController(length: 2, vsync: this); + _navSearchButtonOffset = + 48 * MediaQuery.of(Get.context!).textScaleFactor + 48; + scrollController.addListener(() { - if (scrollController.offset >= 90 && !showNavSearchButton.value) { + if (scrollController.offset >= _navSearchButtonOffset && + !showNavSearchButton.value) { showNavSearchButton.value = true; - } else if (scrollController.offset < 90 && showNavSearchButton.value) { + } else if (scrollController.offset < _navSearchButtonOffset && + showNavSearchButton.value) { showNavSearchButton.value = false; } }); @@ -93,7 +102,8 @@ class HomeTab extends StatelessWidget { duration: const Duration(milliseconds: 100), child: CupertinoButton( padding: EdgeInsets.zero, - child: const Icon(CupertinoIcons.search, size: 26, color: Styles.themeNavTitleColor), + child: const Icon(CupertinoIcons.search, + size: 26, color: Styles.themeNavTitleColor), onPressed: () { onSearchClick?.call(); }, @@ -111,7 +121,8 @@ class HomeTab extends StatelessWidget { duration: const Duration(milliseconds: 100), child: CupertinoButton( padding: EdgeInsets.zero, - child: const Icon(CupertinoIcons.search, size: 26, color: Styles.themeNavTitleColor), + child: const Icon(CupertinoIcons.search, + size: 26, color: Styles.themeNavTitleColor), onPressed: () { onSearchClick?.call(); }, @@ -132,11 +143,12 @@ class HomeTab extends StatelessWidget { parent: AlwaysScrollableScrollPhysics(), ), slivers: [ - CupertinoSliverNavigationBar( + IsekaiSliverNavigationBar( leading: _buildNotificationIconButton(), backgroundColor: Styles.themeMainColor, brightness: Brightness.dark, - largeTitle: const Text('首页', style: TextStyle(color: Styles.themeNavTitleColor)), + largeTitle: const Text('首页', + style: TextStyle(color: Styles.themeNavTitleColor)), border: Border.all(style: BorderStyle.none), trailing: _buildSearchIconButton(), ), @@ -169,10 +181,12 @@ class HomeTab extends StatelessWidget { children: [ Container( padding: const EdgeInsets.all(1), - child: const Icon(CupertinoIcons.search, color: Colors.black54), + child: const Icon(CupertinoIcons.search, + color: Colors.black54), ), const Text("搜索页面...", - textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)) + textAlign: TextAlign.center, + style: TextStyle(color: Colors.black54)) ], ), ), @@ -202,7 +216,10 @@ class HomeTab extends StatelessWidget { indicatorColor: Styles.themeMainColor, labelColor: Styles.themeMainColor, unselectedLabelColor: Colors.black45, - tabs: const [CollapsedTabText('最新'), CollapsedTabText('关注')], + tabs: const [ + CollapsedTabText('最新'), + CollapsedTabText('关注') + ], onTap: (int selected) {}, ), ), @@ -247,7 +264,8 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { double get maxExtent => max(maxHeight, minHeight); @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { return SizedBox.expand(child: child); } diff --git a/lib/pages/own_profile.dart b/lib/pages/own_profile.dart index 741b980..05ccd4b 100755 --- a/lib/pages/own_profile.dart +++ b/lib/pages/own_profile.dart @@ -1,6 +1,8 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:cupertino_lists/cupertino_lists.dart'; import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; +import 'package:isekai_wiki/components/isekai_nav_bar.dart'; import 'package:isekai_wiki/models/user.dart'; import 'package:isekai_wiki/pages/about.dart'; import 'package:isekai_wiki/styles.dart'; @@ -11,8 +13,6 @@ import '../components/follow_scale.dart'; class OwnProfileController extends GetxController { late UserController uc; - var loginLoading = false.obs; - @override void onInit() { super.onInit(); @@ -25,16 +25,51 @@ class OwnProfileController extends GetxController { } Future handleStartAuth() async { - loginLoading.value = true; await uc.startAuthFlow(); - loginLoading.value = false; + } + + Future handleLogoutClick() { + handleLogout(); + return Future.delayed(const Duration(milliseconds: 100)); + } + + Future handleLogout() async { + await uc.logout(); } } class OwnProfileTab extends StatelessWidget { const OwnProfileTab({super.key}); - Widget _buildUserSection() { + Widget _buildUserAvatar(UserController uc, {double size = 56}) { + return Obx(() { + var avatarUrl = uc.getAvatar(128); + + if (avatarUrl != null && avatarUrl.isNotEmpty) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(size / 2)), + ), + child: CachedNetworkImage( + width: size, + height: size, + placeholder: (_, __) => + const CupertinoActivityIndicator(radius: 12), + imageUrl: avatarUrl, + ), + ); + } else { + return DummyIcon( + color: CupertinoColors.systemGrey, + icon: CupertinoIcons.person_fill, + size: size, + rounded: true, + ); + } + }); + } + + Widget _buildUserSection(BuildContext context) { var c = Get.find(); var uc = Get.find(); @@ -57,7 +92,7 @@ class OwnProfileTab extends StatelessWidget { ), leadingSize: 80, leadingToTitle: 4, - trailing: c.loginLoading.value + trailing: uc.authProcessing.value ? const Padding( padding: EdgeInsets.only(right: 5), child: CupertinoActivityIndicator( @@ -75,12 +110,7 @@ class OwnProfileTab extends StatelessWidget { CupertinoListTile.notched( title: Text(uc.getDisplayName, style: Styles.listTileLargeTitle), - leading: const DummyIcon( - color: CupertinoColors.systemGrey, - icon: CupertinoIcons.person_fill, - size: 56, - rounded: true, - ), + leading: _buildUserAvatar(uc), leadingSize: 80, leadingToTitle: 4, trailing: const CupertinoListTileChevron(), @@ -90,7 +120,7 @@ class OwnProfileTab extends StatelessWidget { title: const Text('退出登录'), leading: const DummyIcon( color: CupertinoColors.systemRed, - icon: CupertinoIcons.selection_pin_in_out, + icon: CupertinoIcons.arrow_right_square, ), trailing: const CupertinoListTileChevron(), onTap: () {}, @@ -101,7 +131,7 @@ class OwnProfileTab extends StatelessWidget { ); } - Widget _buildArticleListsSection() { + Widget _buildArticleListsSection(BuildContext context) { return FollowTextScale( child: CupertinoListSection.insetGrouped( backgroundColor: Styles.themePageBackgroundColor, @@ -137,7 +167,7 @@ class OwnProfileTab extends StatelessWidget { )); } - Widget _buildSettingsSection() { + Widget _buildSettingsSection(BuildContext context) { return FollowTextScale( child: CupertinoListSection.insetGrouped( backgroundColor: Styles.themePageBackgroundColor, @@ -159,24 +189,28 @@ class OwnProfileTab extends StatelessWidget { ), trailing: const CupertinoListTileChevron(), onTap: () async { - await Navigator.of(Get.context!, rootNavigator: true) - .push(CupertinoPageRoute(builder: (_) => const AboutPage())); + await Navigator.of(context, rootNavigator: false).push( + CupertinoPageRoute( + builder: (_) => const AboutPage(), + ), + ); }, ), ], )); } - SliverChildBuilderDelegate _buildSliverChildBuilderDelegate() { + SliverChildBuilderDelegate _buildSliverChildBuilderDelegate( + BuildContext context) { return SliverChildBuilderDelegate( (context, index) { switch (index) { case 0: - return _buildUserSection(); + return _buildUserSection(context); case 1: - return _buildArticleListsSection(); + return _buildArticleListsSection(context); case 2: - return _buildSettingsSection(); + return _buildSettingsSection(context); default: // Do nothing. For now. } @@ -191,14 +225,14 @@ class OwnProfileTab extends StatelessWidget { return CustomScrollView( slivers: [ - const CupertinoSliverNavigationBar( + const IsekaiSliverNavigationBar( largeTitle: Text('我的'), ), SliverSafeArea( top: false, minimum: const EdgeInsets.only(top: 4), sliver: SliverList( - delegate: _buildSliverChildBuilderDelegate(), + delegate: _buildSliverChildBuilderDelegate(context), ), ) ], diff --git a/lib/utils/api_utils.dart b/lib/utils/api_utils.dart index aa8dd95..9dc4613 100644 --- a/lib/utils/api_utils.dart +++ b/lib/utils/api_utils.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:isekai_wiki/global.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import '../extension/string.dart'; @@ -15,9 +14,7 @@ class ApiUtils { String osName = Platform.operatingSystem.capitalize(); String osVersion = Platform.operatingSystemVersion; - Global.packageInfo ??= await PackageInfo.fromPlatform(); - - String appVersion = Global.packageInfo!.version; + String appVersion = Global.packageInfo?.version ?? "0.0"; return "IsekaiWikiApp/$appVersion ($osName $osVersion)"; } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3549e45..240b101 100755 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,15 @@ import FlutterMacOS import Foundation import package_info_plus +import path_provider_macos import shared_preferences_macos +import sqflite import wakelock_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 6bab030..48cf61a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -99,6 +99,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "8.4.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.3" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" characters: dependency: transitive description: @@ -155,6 +176,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.1" + cookie_jar: + dependency: transitive + description: + name: cookie_jar + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" crypto: dependency: transitive description: @@ -190,6 +218,34 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.4" + dio: + dependency: "direct main" + description: + name: dio + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.6" + dio_cache_interceptor: + dependency: "direct main" + description: + name: dio_cache_interceptor + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.1" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + dio_http2_adapter: + dependency: "direct main" + description: + name: dio_http2_adapter + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" equatable: dependency: transitive description: @@ -230,6 +286,20 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_blurhash: + dependency: transitive + description: + name: flutter_blurhash + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.0" flutter_displaymode: dependency: "direct main" description: @@ -351,12 +421,19 @@ packages: source: hosted version: "0.15.1" http: - dependency: "direct main" + dependency: transitive description: name: http url: "https://pub.dartlang.org" source: hosted version: "0.13.5" + http2: + dependency: transitive + description: + name: http2 + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" http_multi_server: dependency: transitive description: @@ -476,6 +553,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.1" + octo_image: + dependency: transitive + description: + name: octo_image + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" package_config: dependency: transitive description: @@ -518,6 +602,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.1" + path_provider: + dependency: transitive + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.22" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" path_provider_linux: dependency: transitive description: @@ -525,6 +630,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.7" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" path_provider_platform_interface: dependency: transitive description: @@ -539,6 +651,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.3" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.1" petitparser: dependency: transitive description: @@ -616,6 +735,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + rxdart: + dependency: transitive + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.27.7" shared_preferences: dependency: "direct main" description: @@ -719,6 +845,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.9.0" + sqflite: + dependency: transitive + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.0+2" stack_trace: dependency: transitive description: @@ -747,6 +887,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.1" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0+3" term_glyph: dependency: transitive description: @@ -782,6 +929,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.7" vector_math: dependency: transitive description: @@ -938,4 +1092,4 @@ packages: version: "3.1.1" sdks: dart: ">=2.18.0 <3.0.0" - flutter: ">=3.0.0" + flutter: ">=3.3.0" diff --git a/pubspec.yaml b/pubspec.yaml index 81b126c..aa754f8 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,7 +57,11 @@ dependencies: ruby_text: ^3.0.1 package_info_plus: ^3.0.2 pull_down_button: ^0.4.1 - http: ^0.13.5 + cached_network_image: ^3.2.3 + dio: ^4.0.6 + dio_cookie_manager: ^2.0.0 + dio_http2_adapter: ^2.0.0 + dio_cache_interceptor: ^3.3.1 get: dev_dependencies: