feat(app): 完善播放协议搜索与空状态

This commit is contained in:
Zhou Weidong committed 2026-10-23 12:38:47 +08:00
1 parent 671e0ce143
commit ef29fc5f15
85 files changed
+2976 -768

No files matched your search

+67 -1
View File
@@ -14,6 +14,7 @@ class PlayTicket {
this.unlockCoin = 0,
this.previewSeconds = 0,
this.qualities = const [],
this.tracks = const [],
this.thumbnailsUrl,
this.likeCount,
this.commentCount,
@@ -39,8 +40,11 @@ class PlayTicket {
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
final int previewSeconds;
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
/// 后端下发的清晰度档位(如 AUTO / 720p / 1080p)。
final List<String> qualities;
/// 各档签名地址。AUTO 是主列表,点选其它档位时换成对应地址。
final List<PlayQualityTrack> tracks;
final String? thumbnailsUrl;
final int? likeCount;
final int? commentCount;
@@ -64,6 +68,7 @@ class PlayTicket {
.whereType<String>()
.where((quality) => quality.isNotEmpty)
.toList(),
tracks: _tracks(json['tracks'], json['playUrl'] as String? ?? ''),
thumbnailsUrl: _optionalString(json['thumbnailsUrl']),
likeCount: json['likeCount'] == null
? null
@@ -75,6 +80,67 @@ class PlayTicket {
}
}
List<PlayQualityTrack> _tracks(dynamic raw, String playUrl) {
final tracks = <PlayQualityTrack>[];
if (raw is List) {
for (final item in raw) {
if (item is! Map) continue;
final quality = item['quality'] as String? ?? '';
final url = item['playUrl'] as String? ?? '';
if (quality.isEmpty || url.isEmpty) continue;
tracks.add(PlayQualityTrack(quality: quality, playUrl: url));
}
}
if (tracks.isEmpty && playUrl.isNotEmpty) {
tracks.add(PlayQualityTrack(quality: 'AUTO', playUrl: playUrl));
}
return tracks;
}
/// 按票据里的分轨地址取播放地址;没有分轨时,从主列表路径推出同目录的单档地址。
String? qualityPlayUrl({
required String quality,
required List<PlayQualityTrack> tracks,
required String currentUrl,
}) {
final wanted = quality.toUpperCase();
for (final track in tracks) {
if (track.quality.toUpperCase() == wanted && track.playUrl.isNotEmpty) {
return track.playUrl;
}
}
return siblingRenditionUrl(currentUrl, wanted);
}
/// `.../master.m3u8` 与 `.../720p/index.m3u8` 互相换算,查询参数原样保留。
String? siblingRenditionUrl(String currentUrl, String quality) {
final uri = Uri.tryParse(currentUrl);
if (uri == null || uri.path.isEmpty) return null;
final path = uri.path;
const masterSuffix = '/master.m3u8';
String? root;
if (path.endsWith(masterSuffix)) {
root = path.substring(0, path.length - masterSuffix.length);
} else {
final match = RegExp(r'/(\d+p)/index\.m3u8$').firstMatch(path);
if (match == null) return null;
root = path.substring(0, match.start);
}
final next = quality.toUpperCase() == 'AUTO'
? '$root$masterSuffix'
: '$root/${quality.toLowerCase()}/index.m3u8';
if (next == path) return currentUrl;
return uri.replace(path: next).toString();
}
/// 一档清晰度的签名播放地址。
class PlayQualityTrack {
const PlayQualityTrack({required this.quality, required this.playUrl});
final String quality;
final String playUrl;
}
String? _optionalString(dynamic value) {
final text = value is String ? value.trim() : '';
return text.isEmpty ? null : text;
+8
View File
@@ -18,6 +18,7 @@ class WatchProgress {
this.totalEpisodes,
this.finished = false,
this.playCount = 0,
this.viewCount,
this.updatedAt,
});
@@ -31,6 +32,9 @@ class WatchProgress {
final Duration duration;
final bool finished;
final int playCount;
/// 短剧热度。历史列表没下发时为空,不能拿用户自己的 playCount 冒充。
final int? viewCount;
final DateTime? updatedAt;
bool get hasPosition => position > Duration.zero;
@@ -57,6 +61,9 @@ class WatchProgress {
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true,
playCount: jsonToInt(json['playCount']),
viewCount: json['viewCount'] == null
? null
: jsonToInt(json['viewCount']),
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''),
);
}
@@ -72,6 +79,7 @@ class WatchProgress {
'durationMs': duration.inMilliseconds,
'finished': finished,
'playCount': playCount,
if (viewCount != null) 'viewCount': viewCount,
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
};
}
+2 -1
View File
@@ -148,7 +148,8 @@ class ApiClient {
skipAuthRetry: skipAuthRetry,
);
}
onUnauthorized?.call();
final token = accessTokenReader?.call();
if (token == null || token.isEmpty) onUnauthorized?.call();
throw ApiException(401, cause.message ?? '登录已过期');
}
if (cause is ApiException) {
+18 -4
View File
@@ -33,6 +33,7 @@ class AuthApi {
required String code,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
final data = await _client.post(
@@ -43,6 +44,7 @@ class AuthApi {
'code': code,
'deviceId': deviceId,
'platform': platform,
'osVersion': osVersion,
'appVersion': appVersion,
},
);
@@ -55,6 +57,7 @@ class AuthApi {
required String code,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
final data = await _client.post(
@@ -65,6 +68,7 @@ class AuthApi {
'code': code,
'deviceId': deviceId,
'platform': platform,
'osVersion': osVersion,
'appVersion': appVersion,
},
);
@@ -76,6 +80,7 @@ class AuthApi {
required String password,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
final data = await _client.post(
@@ -87,6 +92,7 @@ class AuthApi {
'countryCode': 'ID',
'deviceId': deviceId,
'platform': platform,
'osVersion': osVersion,
'appVersion': appVersion,
},
);
@@ -98,6 +104,7 @@ class AuthApi {
required String password,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
final data = await _client.post(
@@ -108,18 +115,25 @@ class AuthApi {
'password': password,
'deviceId': deviceId,
'platform': platform,
'osVersion': osVersion,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<AuthSession> refresh(String refreshToken) async {
Future<AuthSession> refresh(
String refreshToken, {
String platform = '',
String osVersion = '',
}) async {
final data = await _client.post(
'/app/auth/refresh',
body: {'refreshToken': refreshToken},
// 刷新请求本身不带旧令牌重试,失败就是失败
skipAuthRetry: true,
body: {
'refreshToken': refreshToken,
'platform': platform,
'osVersion': osVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
+5
View File
@@ -36,4 +36,9 @@ class LibraryApi {
Future<void> removeFavorite(String dramaId) async {
await _client.delete('/app/favorites/$dramaId');
}
/// 一次取消多部短剧,请求体为 dramaIds 数组。
Future<void> removeFavorites(List<String> dramaIds) async {
await _client.post('/app/favorites/remove', body: {'dramaIds': dramaIds});
}
}
@@ -13,6 +13,9 @@ abstract interface class FavoriteRepository {
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20});
Future<void> remove(String dramaId);
/// 一次取消多部短剧。
Future<void> removeMany(List<String> dramaIds);
}
class HttpFavoriteRepository implements FavoriteRepository {
@@ -36,6 +39,10 @@ class HttpFavoriteRepository implements FavoriteRepository {
@override
Future<void> remove(String dramaId) => _api.removeFavorite(dramaId);
@override
Future<void> removeMany(List<String> dramaIds) =>
_api.removeFavorites(dramaIds);
}
/// 内存实现:单测与 widget 测试注入用。
@@ -51,6 +58,7 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
bool failRemove = false;
int toggleCalls = 0;
int pageCalls = 0;
int removeManyCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
@@ -80,4 +88,11 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
if (failRemove) throw StateError('favorite unavailable');
_saved.remove(dramaId);
}
@override
Future<void> removeMany(List<String> dramaIds) async {
removeManyCalls += 1;
if (failRemove) throw StateError('favorite unavailable');
_saved.removeAll(dramaIds);
}
}
+48 -16
View File
@@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import '../models/auth_session.dart';
@@ -9,7 +11,7 @@ import 'auth_session_store.dart';
///
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。
/// 401 → 刷新 → 重试。令牌被拒绝才清会话,服务中断时保持登录。
class AuthController extends ChangeNotifier {
AuthController(this._api, this._store);
@@ -19,6 +21,10 @@ class AuthController extends ChangeNotifier {
AuthSession? _session;
String _deviceId = '';
bool _loaded = false;
Future<bool>? _refreshing;
/// 登录已过期、账号停用才清本地会话。服务不可达不能当成退出登录。
static const Set<int> _rejectedSessionCodes = {401, 100001};
bool get isLoaded => _loaded;
bool get isSignedIn => _session != null;
@@ -43,6 +49,7 @@ class AuthController extends ChangeNotifier {
password: password,
deviceId: await _store.deviceId(),
platform: _platform,
osVersion: _osVersion,
);
await _apply(session);
}
@@ -53,14 +60,12 @@ class AuthController extends ChangeNotifier {
password: password,
deviceId: await _store.deviceId(),
platform: _platform,
osVersion: _osVersion,
);
await _apply(session);
}
Future<void> sendOtp({
required String target,
String scene = 'LOGIN',
}) async {
Future<void> sendOtp({required String target, String scene = 'LOGIN'}) async {
await _api.sendOtp(
target: target,
scene: scene,
@@ -77,6 +82,7 @@ class AuthController extends ChangeNotifier {
code: code,
deviceId: await _store.deviceId(),
platform: _platform,
osVersion: _osVersion,
);
await _apply(session);
}
@@ -90,10 +96,17 @@ class AuthController extends ChangeNotifier {
code: code,
deviceId: await _store.deviceId(),
platform: _platform,
osVersion: _osVersion,
);
await _apply(session);
}
Future<void> clear() async {
_session = null;
await _store.clear();
notifyListeners();
}
Future<void> signOut() async {
final refreshToken = _session?.refreshToken ?? '';
_session = null;
@@ -107,29 +120,43 @@ class AuthController extends ChangeNotifier {
}
}
/// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。
Future<bool> refresh() async {
/// 供 [ApiClient] 调用:刷新令牌并落盘。
///
/// 同一时刻只刷新一次,避免重启期间并发 401 把新令牌轮换掉。
/// 网络失败或 5xx 保留本地会话;只有令牌被拒绝才退出登录。
Future<bool> refresh() {
final current = _refreshing;
if (current != null) return current;
final run = _refreshSingle();
_refreshing = run;
return run.whenComplete(() {
if (identical(_refreshing, run)) _refreshing = null;
});
}
Future<bool> _refreshSingle() async {
final refreshToken = _session?.refreshToken ?? '';
if (refreshToken.isEmpty) {
await clear();
return false;
}
try {
await _apply(await _api.refresh(refreshToken));
await _apply(
await _api.refresh(
refreshToken,
platform: _platform,
osVersion: _osVersion,
),
);
return true;
} on ApiException catch (error) {
if (_rejectedSessionCodes.contains(error.code)) await clear();
return false;
} catch (_) {
await clear();
return false;
}
}
Future<void> clear() async {
if (_session == null) return;
_session = null;
await _store.clear();
notifyListeners();
}
/// 资料保存成功后同步登录态摘要,保留当前令牌。
Future<void> applyProfile(AuthUser user) async {
final current = _session;
@@ -154,4 +181,9 @@ class AuthController extends ChangeNotifier {
if (kIsWeb) return 'WEB';
return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID';
}
String get _osVersion {
if (kIsWeb) return '';
return Platform.operatingSystemVersion;
}
}
@@ -161,6 +161,37 @@ class FavoriteController extends ChangeNotifier {
}
}
/// 一次取消多部收藏。失败时整批回滚。
Future<bool> removeMany(List<String> dramaIds) async {
final ids = dramaIds.toSet();
if (ids.isEmpty) return true;
final previousCards = cards;
final previousTotal = total;
final previousHasMore = hasMore;
final removed = cards.where((drama) => ids.contains(drama.id)).toList();
if (removed.isEmpty) return true;
cards = cards.where((drama) => !ids.contains(drama.id)).toList();
for (final id in ids) {
_saved.remove(id);
}
total = total > removed.length ? total - removed.length : 0;
hasMore = cards.length < total;
notify();
try {
await _repository.removeMany(dramaIds);
return true;
} catch (_) {
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
for (final drama in removed) {
_saved.add(drama.id);
}
notify();
return false;
}
}
/// 取消收藏("我的"页列表里删除某条)。
Future<bool> remove(String dramaId) async {
final index = cards.indexWhere((drama) => drama.id == dramaId);
+120 -2
View File
@@ -5,6 +5,10 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../models/player_port.dart';
import 'video_player_port.dart';
/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。
const Duration _qualityHandoffHold = Duration(milliseconds: 200);
typedef PlayerFactory = PlayerPort Function(Episode episode);
typedef PlayerPreparation =
@@ -66,6 +70,7 @@ class PlaybackSession extends ChangeNotifier {
final Set<_PlayerSlot> _live = {};
_PlayerSlot? _active;
_PlayerSlot? _next;
_PlayerSlot? _handoff;
Future<void> _operations = Future<void>.value();
Future<void> _preloadTask = Future<void>.value();
int _generation = 0;
@@ -127,6 +132,15 @@ class PlaybackSession extends ChangeNotifier {
return null;
}
/// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。
PlayerPort? handoffAt(int index) {
final slot = _handoff;
if (_closed || slot == null || slot.retiring || slot.index != index) {
return null;
}
return slot.player;
}
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
void remapCurrentIndex(int index, {int? preloadIndex}) {
if (_closed || index < 0 || index >= episodes.length) return;
@@ -184,9 +198,10 @@ class PlaybackSession extends ChangeNotifier {
Future<void> _retire(_PlayerSlot? slot) async {
if (slot == null || !_live.contains(slot)) return;
slot.retiring = true;
final cleared = _active == slot || _next == slot;
final cleared = _active == slot || _next == slot || _handoff == slot;
if (_active == slot) _active = null;
if (_next == slot) _next = null;
if (_handoff == slot) _handoff = null;
if (cleared) _notify();
await waitForFrame?.call();
await slot.ready;
@@ -235,6 +250,7 @@ class PlaybackSession extends ChangeNotifier {
_PlayerSlot? target;
await _enqueue(() async {
if (_closed || request != _generation) return;
await _retire(_handoff);
final old = _active;
if (old != null &&
old.index == index &&
@@ -272,6 +288,108 @@ class PlaybackSession extends ChangeNotifier {
if (!_closed && request == _generation) _schedulePreload();
}
/// 后台准备新片源,成功后再替换当前播放器。失败时原播放器保持不动。
Future<bool> replaceCurrent() async {
if (_closed || !_selected) return false;
final index = _currentIndex;
if (index < 0 || index >= episodes.length || isLocked) return false;
if (!canPlay(episodes[index])) return false;
final request = _generation;
final slot = await _prepareReplacement(index, request);
if (slot == null) {
await _resumeActive();
return false;
}
await slot.ready;
if (!_replacementReady(slot, request)) {
await _dropReplacement(slot);
await _resumeActive();
return false;
}
final previous = await _showReplacement(slot, request);
if (_active != slot) {
await _dropReplacement(slot);
return false;
}
await _enqueue(() => _start(slot, request));
await Future<void>.delayed(_qualityHandoffHold);
if (previous != null && _handoff == previous && _active == slot) {
await _enqueue(() => _retire(previous));
}
if (!_closed && request == _generation) _schedulePreload();
return _active == slot && slot.failure == null;
}
Future<_PlayerSlot?> _prepareReplacement(int index, int request) async {
_PlayerSlot? created;
await _enqueue(() async {
if (_closed || request != _generation || _active == null) return;
final warm = _next;
_next = null;
await _retire(_handoff);
await _retire(warm);
if (_closed || request != _generation || _active == null) return;
try {
created = _create(index);
} catch (_) {
created = null;
}
});
return created;
}
bool _replacementReady(_PlayerSlot slot, int request) {
return !_closed &&
request == _generation &&
_active != null &&
slot.failure == null &&
slot.prepared;
}
Future<void> _resumeActive() async {
final slot = _active;
if (slot == null || !_userWantsPlay || _closed) return;
await _enqueue(() => _applyPlayback(slot));
}
Future<void> _dropReplacement(_PlayerSlot slot) {
return _enqueue(() async {
if (_live.contains(slot)) await _retire(slot);
});
}
/// 先把已出画的新播放器交到界面上,旧播放器留给调用方稍后释放。
Future<_PlayerSlot?> _showReplacement(_PlayerSlot slot, int request) async {
_PlayerSlot? previous;
var shown = false;
await _enqueue(() async {
if (!_replacementReady(slot, request)) return;
previous = _active;
await _catchUp(slot, previous);
_handoff = previous;
_active = slot;
shown = true;
if (previous != null) await previous!.player.pause();
_notify();
});
if (!shown || _active != slot) return null;
return previous;
}
/// 旧片仍在播,把新片对齐到当前进度,盖住时画面才接得上。
Future<void> _catchUp(_PlayerSlot slot, _PlayerSlot? previous) async {
final next = slot.player;
final current = previous?.player;
if (next is! VideoPlayerPort || current is! VideoPlayerPort) return;
final position = current.controller.value.position;
if (position <= Duration.zero) return;
try {
await next.seekTo(position);
} catch (_) {
// 对齐失败仍用准备阶段的进度,不因此放弃切换。
}
}
Future<void> _start(_PlayerSlot slot, int request) async {
bool valid() => !_closed && request == _generation && _active == slot;
if (!valid()) return;
@@ -387,7 +505,7 @@ class PlaybackSession extends ChangeNotifier {
Future<void> _releasePlayers() {
_generation++;
return _enqueue(() async {
_active = _next = null;
_active = _next = _handoff = null;
_loading = false;
_notify();
for (final slot in _live.toList()) {
+8
View File
@@ -2,6 +2,14 @@
import '../../l10n/app_localizations.dart';
const int _serverErrorCode = 500;
/// 用户端不展示内部故障文案。500 与空提示改成可理解的说明,业务提示原样保留。
String userFacingApiMessage(AppLocalizations l10n, int code, String message) {
if (code >= _serverErrorCode || message.isEmpty) return l10n.authServerError;
return message;
}
/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与
/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。
String errorText(AppLocalizations l10n, String? code, String fallback) {
+127
View File
@@ -0,0 +1,127 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import '../theme/app_colors.dart';
const double _bodySize = 16;
const double _headingSize = 18;
const double _blockGap = 12;
const double _lineHeight = 1.7;
const TextStyle _bodyStyle = TextStyle(
color: AppColors.textPrimary,
fontSize: _bodySize,
height: _lineHeight,
);
/// 把协议页的 HTML 正文画成普通控件。
class LegalArticleView extends StatelessWidget {
const LegalArticleView({super.key, required this.html});
final String html;
@override
Widget build(BuildContext context) {
final fragment = html_parser.parseFragment(html);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final node in fragment.nodes) _block(node),
],
);
}
}
Widget _block(dom.Node node) {
if (node is! dom.Element) {
return _paragraph(node.text ?? '');
}
switch (node.localName) {
case 'h1':
case 'h2':
case 'h3':
return _heading(node.text);
case 'ul':
case 'ol':
return _list(node);
case 'pre':
return _paragraph(node.text);
case 'blockquote':
return _paragraph(node.text);
default:
return _paragraphSpan(node);
}
}
Widget _heading(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: _blockGap),
child: Text(
text.trim(),
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _headingSize,
height: 1.4,
fontWeight: FontWeight.w700,
),
),
);
}
Widget _list(dom.Element element) {
final items = element.children.where((child) => child.localName == 'li');
return Padding(
padding: const EdgeInsets.only(bottom: _blockGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final item in items)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('• ${item.text.trim()}', style: _bodyStyle),
),
],
),
);
}
Widget _paragraph(String text) {
final value = text.trim();
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: _blockGap),
child: Text(value, style: _bodyStyle),
);
}
Widget _paragraphSpan(dom.Element element) {
return Padding(
padding: const EdgeInsets.only(bottom: _blockGap),
child: Text.rich(TextSpan(style: _bodyStyle, children: _inlines(element))),
);
}
List<InlineSpan> _inlines(dom.Node node) {
if (node is dom.Text) {
return [TextSpan(text: node.text)];
}
if (node is! dom.Element) return const [];
final children = node.nodes.expand(_inlines).toList();
switch (node.localName) {
case 'strong':
case 'b':
return [TextSpan(style: const TextStyle(fontWeight: FontWeight.w700), children: children)];
case 'em':
case 'i':
return [TextSpan(style: const TextStyle(fontStyle: FontStyle.italic), children: children)];
case 'a':
return [TextSpan(style: const TextStyle(color: AppColors.blue), children: children)];
case 'br':
return const [TextSpan(text: '\n')];
default:
return children;
}
}
+36
View File
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
import 'package:dio/dio.dart';
import '../config/app_config.dart';
final RegExp _articlePattern = RegExp(
'<article>([\\s\\S]*)</article>',
caseSensitive: false,
);
/// 从协议页里取出正文。没有 article 时退回整页。
String legalArticle(String html) {
final match = _articlePattern.firstMatch(html);
final body = match?.group(1);
if (body == null || body.trim().isEmpty) return html;
return body.trim();
}
/// 拉取协议页 HTML。不走业务 JSON 包装。
Future<String> fetchLegalArticle(String url) async {
final dio = Dio(
BaseOptions(
connectTimeout: AppConfig.requestTimeout,
receiveTimeout: AppConfig.requestTimeout,
responseType: ResponseType.plain,
validateStatus: (code) => code != null && code >= 200 && code < 300,
),
);
final response = await dio.get<String>(url);
final body = response.data;
if (body == null || body.trim().isEmpty) {
throw StateError('empty legal page');
}
return legalArticle(body);
}
+12
View File
@@ -0,0 +1,12 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'legal_web_page.dart';
/// 在当前 App 里打开协议地址。
Future<void> openLegalUrlOrNotify(BuildContext context, String url) {
return Navigator.of(context).push<void>(
MaterialPageRoute<void>(builder: (_) => LegalWebPage(url: url)),
);
}
+27
View File
@@ -0,0 +1,27 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../config/app_config.dart';
/// 隐私协议公开路径,对应 `/privacy/{语言}`。
const String legalPrivacyPath = 'privacy';
/// 服务条款公开路径,对应 `/terms/{语言}`。
const String legalTermsPath = 'terms';
const Set<String> _legalLanguages = {'zh', 'en', 'id'};
/// 界面语言落到协议页支持的三种语言。其余语言用英文。
String legalLanguageCode(Locale locale) {
final code = locale.languageCode;
if (_legalLanguages.contains(code)) return code;
return 'en';
}
/// 拼出当前服务器上的协议页地址。
String legalPageUrl(String path, Locale locale) {
final base = AppConfig.apiBaseUrl;
final root = base.endsWith('/') ? base.substring(0, base.length - 1) : base;
return '$root/$path/${legalLanguageCode(locale)}';
}
+96
View File
@@ -0,0 +1,96 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../theme/app_colors.dart';
import '../widgets/plain_page_scaffold.dart';
import 'legal_article_view.dart';
import 'legal_html.dart';
const double _pagePadding = 20;
/// 在应用内展示协议页,不使用系统 WebView。
class LegalWebPage extends StatefulWidget {
const LegalWebPage({super.key, required this.url, this.load});
final String url;
/// 测试时可替换网络请求。
final Future<String> Function(String url)? load;
@override
State<LegalWebPage> createState() => _LegalWebPageState();
}
class _LegalWebPageState extends State<LegalWebPage> {
String? _html;
var _failed = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_html = null;
_failed = false;
});
try {
final loader = widget.load ?? fetchLegalArticle;
final html = await loader(widget.url);
if (!mounted) return;
setState(() => _html = html);
} catch (_) {
if (!mounted) return;
setState(() => _failed = true);
}
}
@override
Widget build(BuildContext context) {
return PlainPageScaffold(
pageKey: const Key('legal-web-page'),
backKey: const Key('legal-web-back'),
title: '',
child: _body(context),
);
}
Widget _body(BuildContext context) {
final html = _html;
if (html != null) {
return ListView(
padding: const EdgeInsets.fromLTRB(_pagePadding, 8, _pagePadding, 28),
children: [
LegalArticleView(html: html),
],
);
}
if (_failed) return _failure(context);
return const Center(
child: CircularProgressIndicator(
key: Key('legal-web-loading'),
color: AppColors.blue,
),
);
}
Widget _failure(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.legalOpenFailed, style: const TextStyle(color: AppColors.muted)),
TextButton(
key: const Key('legal-web-retry'),
onPressed: _load,
child: Text(l10n.retry),
),
],
),
);
}
}
+1
View File
@@ -105,6 +105,7 @@ abstract final class AppColors {
static const mineIconSupport = Color(0xFFA78BFA);
static const mineIconLanguage = Color(0xFF22D3EE);
static const mineIconSettings = Color(0xFFA1A1AA);
static const mineIconNotices = Color(0xFFFF8A4C);
static const recommendPlaylistGlow = Color(0x26FF6B2C);
/// 次级文字与列表 chevron。
+1 -1
View File
@@ -187,7 +187,7 @@ abstract final class AppDimens {
static const double followingAddIconStroke = 1.5;
static const double followingGridGap = 6;
static const double followingGridRowGap = 12;
static const double followingCardTextExtent = 66;
static const double followingCardTextExtent = 70;
static const double followingSubtitleFont = 12;
static const double followingSubtitleGap = 4;
static const double followingProgressHeight = 2;
+3 -4
View File
@@ -115,7 +115,6 @@ class _EmptyCopy extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -126,10 +125,10 @@ class _EmptyCopy extends StatelessWidget {
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: scheme.onSurface,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: 15,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w400,
height: 1.35,
),
),
+10 -2
View File
@@ -13,7 +13,9 @@ const double _titleFontSize = 18;
const double _subtitleFontSize = 12;
const double _actionGap = 8;
const double _iconSize = 22;
const double _backIconSize = 34;
const double _iconWeight = 600;
const double _backIconWeight = 400;
const double _shadowBlur = 8;
/// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。
@@ -24,6 +26,8 @@ class AppSubpageCircleButton extends StatelessWidget {
required this.tooltip,
required this.icon,
required this.onPressed,
this.iconSize = _iconSize,
this.iconWeight = _iconWeight,
this.dark = false,
});
@@ -31,6 +35,8 @@ class AppSubpageCircleButton extends StatelessWidget {
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
final double iconSize;
final double iconWeight;
final bool dark;
@override
@@ -65,8 +71,8 @@ class AppSubpageCircleButton extends StatelessWidget {
child: Icon(
icon,
color: dark ? AppColors.onSurface : AppColors.textPrimary,
size: _iconSize,
weight: _iconWeight,
size: iconSize,
weight: iconWeight,
),
),
),
@@ -96,6 +102,8 @@ class AppSubpageBackButton extends StatelessWidget {
buttonKey: backKey,
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Icons.chevron_left_rounded,
iconSize: _backIconSize,
iconWeight: _backIconWeight,
dark: dark,
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
);