Files
FlClash/lib/views/disclaimer.dart
T
chen08209 da6fb0a8a1 feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
2026-10-17 17:21:43 +08:00

655 lines
19 KiB
Dart

import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// True only after "Agree" and the restatement; any other way out declines.
Future<bool> requestDisclaimerConsent() async {
return await BaseNavigator.push<bool>(
rootNavigatorKey.currentContext!,
const DisclaimerView(requireConsent: true),
) ??
false;
}
class DisclaimerView extends StatelessWidget {
final bool requireConsent;
const DisclaimerView({super.key, this.requireConsent = false});
static const _googlePrivacyUrl = 'https://policies.google.com/privacy';
static const _firebasePrivacyUrl =
'https://firebase.google.com/support/privacy';
static const _maxContentWidth = 720.0;
List<({String title, String content})> _leadingTerms(AppLocalizations l) => [
(
title: l.disclaimerSoftwareTitle,
content:
'${l.disclaimerSoftwareContent}\n\n${l.disclaimerNoServiceStatement}',
),
(title: l.disclaimerUsageTitle, content: l.disclaimerUsageContent),
(
title: l.disclaimerResponsibilityTitle,
content: l.disclaimerResponsibilityContent,
),
(
title: l.disclaimerThirdPartyTitle,
content: l.disclaimerThirdPartyContent,
),
(title: l.disclaimerWarrantyTitle, content: l.disclaimerWarrantyContent),
(title: l.disclaimerLiabilityTitle, content: l.disclaimerLiabilityContent),
];
List<({String title, String content})> _trailingTerms(AppLocalizations l) => [
(title: l.disclaimerLicenseTitle, content: l.disclaimerLicenseContent),
(title: l.disclaimerChangesTitle, content: l.disclaimerChangesContent),
(title: l.disclaimerAcceptTitle, content: l.disclaimerAcceptContent),
];
static const _mobileGutter = 16.0;
static const _desktopGutter = 24.0;
static const _desktopTopPadding = 48.0;
ListView _buildBody({
required EdgeInsets padding,
required List<Widget> children,
}) {
return ListView(
padding: padding,
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final terms = _buildTerms(appLocalizations);
final body = _buildBody(
padding: const EdgeInsets.symmetric(
horizontal: _mobileGutter,
).copyWith(top: context.contentTopPadding, bottom: 32),
children: terms,
);
if (!requireConsent) {
return CommonScaffold(title: appLocalizations.disclaimer, body: body);
}
return Consumer(
builder: (context, ref, _) {
if (ref.watch(isMobileViewProvider)) {
return CommonScaffold(
title: appLocalizations.disclaimer,
body: _ConsentLayout(
maxWidth: _maxContentWidth,
gutter: _mobileGutter,
compact: true,
child: body,
),
);
}
return Scaffold(
body: SafeArea(
child: _ConsentLayout(
maxWidth: _maxContentWidth,
gutter: _desktopGutter,
compact: false,
child: _buildBody(
padding: const EdgeInsets.fromLTRB(
_desktopGutter,
_desktopTopPadding,
_desktopGutter,
32,
),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
appLocalizations.disclaimer,
style: AppTextStyle.title2.of(context, emphasized: true),
),
),
...terms,
],
),
),
),
);
},
);
}
List<Widget> _buildTerms(AppLocalizations appLocalizations) {
final leadingTerms = _leadingTerms(appLocalizations);
final trailingTerms = _trailingTerms(appLocalizations);
final privacyIndex = leadingTerms.length + 1;
return [
_DisclaimerIntro(text: appLocalizations.disclaimerDesc),
for (final (index, term) in leadingTerms.indexed)
_DisclaimerTerm(
index: index + 1,
title: term.title,
content: term.content,
),
_DisclaimerTerm(
index: privacyIndex,
title: appLocalizations.disclaimerPrivacyTitle,
content: appLocalizations.disclaimerPrivacyContent,
children: [
_DataServiceCard(
glyph: AppGlyphs.error,
title: appLocalizations.disclaimerCrashlyticsTitle,
content: appLocalizations.disclaimerCrashlyticsContent,
),
_DataServiceCard(
glyph: AppGlyphs.dataUsage,
title: appLocalizations.disclaimerAnalyticsTitle,
content: appLocalizations.disclaimerAnalyticsContent,
),
_TermParagraphs(appLocalizations.disclaimerDataProcessingContent),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_PolicyLink(
label: appLocalizations.disclaimerGooglePrivacy,
url: _googlePrivacyUrl,
),
_PolicyLink(
label: appLocalizations.disclaimerFirebasePrivacy,
url: _firebasePrivacyUrl,
),
],
),
],
),
for (final (index, term) in trailingTerms.indexed)
_DisclaimerTerm(
index: privacyIndex + index + 1,
title: term.title,
content: term.content,
),
];
}
}
class _DisclaimerIntro extends StatelessWidget {
final String text;
const _DisclaimerIntro({required this.text});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
return DecoratedBox(
decoration: ShapeDecoration(
color: context.raisedSurfaceLevel.colorOf(colorScheme),
shape: AppShape.xl,
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlyphIcon(AppGlyphs.gavel, size: 28, color: colorScheme.primary),
const SizedBox(width: 16),
Expanded(
child: Text(
text,
style: AppTextStyle.subheadline
.of(context)
.copyWith(color: colorScheme.onSurfaceVariant, height: 1.6),
),
),
],
),
),
);
}
}
class _DisclaimerTerm extends StatelessWidget {
final int index;
final String title;
final String content;
final List<Widget> children;
const _DisclaimerTerm({
required this.index,
required this.title,
required this.content,
this.children = const [],
});
static const _badgeSize = 28.0;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
DecoratedBox(
decoration: ShapeDecoration(
color: context.raisedSurfaceLevel.colorOf(colorScheme),
shape: AppShape.full,
),
child: SizedBox.square(
dimension: _badgeSize,
child: Center(
child: Text(
'$index',
style: AppTextStyle.caption1
.of(context, emphasized: true)
.copyWith(color: colorScheme.onSurfaceVariant),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(title, style: AppTextStyle.headline.of(context)),
),
],
),
const SizedBox(height: 12),
_TermParagraphs(content),
for (final child in children)
Padding(padding: const EdgeInsets.only(top: 12), child: child),
],
),
);
}
}
class _TermParagraphs extends StatelessWidget {
final String text;
const _TermParagraphs(this.text);
@override
Widget build(BuildContext context) {
final style = AppTextStyle.subheadline
.of(context)
.copyWith(color: context.colorScheme.onSurfaceVariant, height: 1.6);
final paragraphs = text.split('\n\n');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (index, paragraph) in paragraphs.indexed)
Padding(
padding: EdgeInsets.only(top: index == 0 ? 0 : 10),
child: Text(paragraph, style: style),
),
],
);
}
}
class _DataServiceCard extends StatelessWidget {
final Glyph glyph;
final String title;
final String content;
const _DataServiceCard({
required this.glyph,
required this.title,
required this.content,
});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final level = context.raisedSurfaceLevel;
return DecoratedBox(
decoration: ShapeDecoration(
color: level.colorOf(colorScheme),
shape: AppShape.xl,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
GlyphIcon(glyph, size: 20, color: colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: AppTextStyle.subheadline.of(
context,
emphasized: true,
),
),
),
const SizedBox(width: 8),
DecoratedBox(
decoration: ShapeDecoration(
color: level.next.colorOf(colorScheme),
shape: AppShape.full,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 2,
),
child: Text(
context.appLocalizations.disclaimerAndroidOnly,
style: AppTextStyle.caption2
.of(context, emphasized: true)
.copyWith(color: colorScheme.onSurfaceVariant),
),
),
),
],
),
const SizedBox(height: 12),
_TermParagraphs(content),
],
),
),
);
}
}
class _PolicyLink extends StatelessWidget {
final String label;
final String url;
const _PolicyLink({required this.label, required this.url});
@override
Widget build(BuildContext context) {
return ElasticButton(
child: TextButton.icon(
onPressed: () {
dialogs.openUrl(url);
},
icon: const GlyphIcon(AppGlyphs.openExternal, size: 18),
label: Text(label),
),
);
}
}
class _ConsentLayout extends StatelessWidget {
final double maxWidth;
final double gutter;
final bool compact;
final Widget child;
const _ConsentLayout({
required this.maxWidth,
required this.gutter,
required this.compact,
required this.child,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(child: child),
_ConsentBar(maxWidth: maxWidth, gutter: gutter, compact: compact),
],
);
}
}
class _ConsentBar extends StatelessWidget {
final double maxWidth;
final double gutter;
final bool compact;
const _ConsentBar({
required this.maxWidth,
required this.gutter,
required this.compact,
});
Widget _buildExitButton(BuildContext context) {
return ElasticButton(
child: OutlinedButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(context.appLocalizations.exit),
),
);
}
Future<void> _handleAgree(BuildContext context) async {
final restated = await dialogs.showCommonDialog<bool>(
context: context,
dismissible: false,
child: const _RestateDialog(),
);
if (restated == true && context.mounted) {
Navigator.of(context).pop(true);
}
}
Widget _buildAgreeButton(BuildContext context) {
return ElasticButton(
child: FilledButton(
onPressed: () => _handleAgree(context),
child: Text(context.appLocalizations.agree),
),
);
}
Widget _buildCompactActions(BuildContext context) {
return Row(
children: [
Expanded(child: _buildExitButton(context)),
const SizedBox(width: 12),
Expanded(child: _buildAgreeButton(context)),
],
);
}
Widget _buildActions(BuildContext context) {
return Row(
children: [
const Spacer(),
_buildExitButton(context),
const SizedBox(width: 12),
_buildAgreeButton(context),
],
);
}
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: SurfaceLevel.page.colorOf(context.colorScheme),
),
child: SafeArea(
top: false,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth + gutter * 2),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: gutter, vertical: 12),
child: compact
? _buildCompactActions(context)
: _buildActions(context),
),
),
),
),
);
}
}
enum _Restatement {
partial,
diverged,
complete;
static _Restatement of(TextEditingValue value, String statement) {
if (value.text.trim() == statement) {
return complete;
}
// Pinyin or kana still being composed is not part of the restatement yet.
final composing = value.composing;
final committed = value.isComposingRangeValid
? composing.textBefore(value.text) + composing.textAfter(value.text)
: value.text;
return statement.startsWith(committed.trimLeft()) ? partial : diverged;
}
}
class _RestateDialog extends StatefulWidget {
const _RestateDialog();
@override
State<_RestateDialog> createState() => _RestateDialogState();
}
class _RestateDialogState extends State<_RestateDialog> {
final _controller = TextEditingController();
bool _acknowledged = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _buildStatement(String statement) {
return DecoratedBox(
decoration: ShapeDecoration(
color: context.raisedSurfaceLevel.colorOf(context.colorScheme),
shape: AppShape.lg,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SelectableText(
statement,
style: AppTextStyle.subheadline.of(context).copyWith(height: 1.6),
),
),
);
}
Widget _buildDialog({
required String title,
required String tip,
required String statement,
required bool canConfirm,
required Widget confirmation,
}) {
final appLocalizations = context.appLocalizations;
return CommonDialog(
title: title,
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(appLocalizations.cancel),
),
TextButton(
onPressed: canConfirm
? () {
Navigator.of(context).pop(true);
}
: null,
child: Text(appLocalizations.confirm),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tip,
style: AppTextStyle.subheadline
.of(context)
.copyWith(color: context.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
_buildStatement(statement),
const SizedBox(height: 16),
confirmation,
],
),
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final statement = appLocalizations.disclaimerNoServiceStatement;
// Typing the statement out takes hundreds of presses on a remote.
if (RemoteFocusAdapter.isEnabledOf(context)) {
return _buildDialog(
title: appLocalizations.disclaimerAcknowledgeTitle,
tip: appLocalizations.disclaimerAcknowledgeTip,
statement: statement,
canConfirm: _acknowledged,
confirmation: generateSection(
items: [
ListItem.checkbox(
title: Text(appLocalizations.disclaimerAcknowledgeLabel),
value: _acknowledged,
onChanged: (value) {
setState(() => _acknowledged = value ?? false);
},
),
],
),
);
}
return ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (context, value, _) {
final restatement = _Restatement.of(value, statement);
return _buildDialog(
title: appLocalizations.disclaimerRestateTitle,
tip: appLocalizations.disclaimerRestateTip,
statement: statement,
canConfirm: restatement == _Restatement.complete,
confirmation: FilledInputTheme(
borderRadius: AppRadius.lg,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: TextField(
controller: _controller,
minLines: 3,
maxLines: null,
decoration: InputDecoration(
hintText: appLocalizations.disclaimerRestateHint,
errorText: restatement == _Restatement.diverged
? appLocalizations.disclaimerRestateMismatch
: null,
),
),
),
);
},
);
}
}