feat(app): 完善播放协议搜索与空状态
This commit is contained in:
1 parent
671e0ce143
commit
ef29fc5f15
85 files changed
+2976
-768
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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)}';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user