* opt: marquee

* fix: bangumi seek

* opt: post panel

* opt: remove deprecated code

* opt: singleton dynController

* fix: music scheme

* feat: MemberVideo jump keep position

* tweak
This commit is contained in:
My-Responsitories
2025-09-04 20:29:02 +08:00
committed by GitHub
parent e8a674ca2a
commit 172389b12b
51 changed files with 1314 additions and 1227 deletions

View File

@@ -15,7 +15,7 @@ class ColorPalette extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Hct hct = Hct.fromInt(color.value);
final Hct hct = Hct.fromInt(color.toARGB32());
final primary = Color(Hct.from(hct.hue, 20.0, 90.0).toInt());
final tertiary = Color(Hct.from(hct.hue + 50, 20.0, 85.0).toInt());
final primaryContainer = Color(Hct.from(hct.hue, 30.0, 50.0).toInt());

View File

@@ -46,7 +46,7 @@ class LoadingWidget extends StatelessWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 20),
decoration: BoxDecoration(
color: theme.dialogBackgroundColor,
color: theme.dialogTheme.backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(15)),
),
child: Column(

View File

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
void autoWrapReportDialog(
Future<void> autoWrapReportDialog(
BuildContext context,
Map<String, Map<int, String>> options,
Future<Map> Function(int reasonType, String? reasonDesc, bool banUid)
@@ -14,30 +14,30 @@ void autoWrapReportDialog(
String? reasonDesc;
bool banUid = false;
late final key = GlobalKey<FormState>();
showDialog(
return showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('举报'),
titlePadding: const EdgeInsets.only(left: 22, top: 16, right: 22),
contentPadding: const EdgeInsets.symmetric(vertical: 5),
actionsPadding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 10,
),
content: Form(
key: key,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: SingleChildScrollView(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
child: Column(
builder: (context) {
return AlertDialog(
title: const Text('举报'),
titlePadding: const EdgeInsets.only(left: 22, top: 16, right: 22),
contentPadding: const EdgeInsets.symmetric(vertical: 5),
actionsPadding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 10,
),
content: Form(
key: key,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: SingleChildScrollView(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
child: Builder(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
@@ -48,13 +48,20 @@ void autoWrapReportDialog(
),
child: Text('请选择举报的理由:'),
),
...options.entries.map(
(entry) => WrapRadioOptionsGroup<int>(
groupTitle: entry.key,
options: entry.value,
selectedValue: reasonType,
onChanged: (value) =>
setState(() => reasonType = value),
RadioGroup(
onChanged: (value) {
reasonType = value;
(context as Element).markNeedsBuild();
},
groupValue: reasonType,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: options.entries.map((entry) {
return WrapRadioOptionsGroup<int>(
groupTitle: entry.key,
options: entry.value,
);
}).toList(),
),
),
if (reasonType == 0)
@@ -66,51 +73,51 @@ void autoWrapReportDialog(
),
),
),
Padding(
padding: const EdgeInsets.only(left: 14, top: 6),
child: CheckBoxText(
text: '拉黑该用户',
onChanged: (value) => banUid = value,
),
),
Padding(
padding: const EdgeInsets.only(left: 14, top: 6),
child: CheckBoxText(
text: '拉黑该用户',
onChanged: (value) => banUid = value,
),
],
),
],
),
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(color: Theme.of(context).colorScheme.outline),
),
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(color: Theme.of(context).colorScheme.outline),
),
),
TextButton(
onPressed: () async {
if (reasonType == null ||
(reasonType == 0 && key.currentState?.validate() != true)) {
return;
TextButton(
onPressed: () async {
if (reasonType == null ||
(reasonType == 0 && key.currentState?.validate() != true)) {
return;
}
SmartDialog.showLoading();
try {
final data = await onSuccess(reasonType!, reasonDesc, banUid);
SmartDialog.dismiss();
if (data['code'] == 0) {
Get.back();
SmartDialog.showToast('举报成功');
} else {
SmartDialog.showToast(data['message']);
}
SmartDialog.showLoading();
try {
final data = await onSuccess(reasonType!, reasonDesc, banUid);
SmartDialog.dismiss();
if (data['code'] == 0) {
Get.back();
SmartDialog.showToast('举报成功');
} else {
SmartDialog.showToast(data['message']);
}
} catch (e) {
SmartDialog.dismiss();
SmartDialog.showToast('提交失败:$e');
}
},
child: const Text('确定'),
),
],
);
},
),
} catch (e) {
SmartDialog.dismiss();
SmartDialog.showToast('提交失败:$e');
}
},
child: const Text('确定'),
),
],
);
},
);
}
@@ -186,8 +193,8 @@ class _CheckBoxTextState extends State<CheckBoxText> {
onTap: () {
setState(() {
_selected = !_selected;
widget.onChanged(_selected);
});
widget.onChanged(_selected);
},
child: Padding(
padding: const EdgeInsets.all(4),

View File

@@ -1,126 +1,124 @@
import 'package:PiliPlus/common/widgets/radio_widget.dart';
import 'package:PiliPlus/http/member.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class MemberReportPanel extends StatefulWidget {
const MemberReportPanel({
super.key,
required this.name,
required this.mid,
});
Future<void> showMemberReportDialog(
BuildContext context, {
required Object? name,
required Object mid,
}) {
final List<bool> reasonList = List.generate(3, (_) => false);
final Set<int> reason = {};
int? reasonV2;
final dynamic name;
final dynamic mid;
@override
State<MemberReportPanel> createState() => _MemberReportPanelState();
}
class _MemberReportPanelState extends State<MemberReportPanel> {
final List<bool> _reasonList = List.generate(3, (_) => false);
final Set<int> _reason = {};
int? _reasonV2;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'举报: ${widget.name}',
style: const TextStyle(fontSize: 18),
),
const SizedBox(height: 4),
Text('uid: ${widget.mid}'),
const SizedBox(height: 10),
const Text('举报内容(必选,可多选)'),
...List.generate(
3,
(index) => _checkBoxWidget(
_reasonList[index],
(value) {
setState(() => _reasonList[index] = value);
if (value) {
_reason.add(index + 1);
} else {
_reason.remove(index + 1);
}
},
const ['头像违规', '昵称违规', '签名违规'][index],
return showDialog(
context: context,
builder: (context) {
final theme = Theme.of(context);
return AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
titleTextStyle: theme.textTheme.bodyMedium,
title: Column(
spacing: 4,
children: [
Text(
'举报: $name',
style: const TextStyle(fontSize: 18),
),
),
const Text('举报理由(单选,非必选)'),
...List.generate(
5,
(index) => RadioWidget<int>(
value: index,
groupValue: _reasonV2,
onChanged: (value) {
setState(() => _reasonV2 = value);
},
title: const ['色情低俗', '不实信息', '违禁', '人身攻击', '赌博诈骗'][index],
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.end,
Text('uid: $mid'),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(color: theme.colorScheme.outline),
const Text('举报内容(必选,可多选)'),
...List.generate(
3,
(index) => Builder(
builder: (context) => CheckboxListTile(
dense: true,
value: reasonList[index],
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
onChanged: (value) {
reasonList[index] = value!;
if (value) {
reason.add(index + 1);
} else {
reason.remove(index + 1);
}
(context as Element).markNeedsBuild();
},
title: Text(const ['头像违规', '昵称违规', '签名违规'][index]),
),
),
),
TextButton(
onPressed: () async {
if (_reason.isEmpty) {
SmartDialog.showToast('至少选择一项作为举报内容');
} else {
Get.back();
var result = await MemberHttp.reportMember(
widget.mid,
reason: _reason.join(','),
reasonV2: _reasonV2 != null ? _reasonV2! + 1 : null,
);
if (result['msg'] is String && result['msg'].isNotEmpty) {
SmartDialog.showToast(result['msg']);
} else {
SmartDialog.showToast('举报失败');
}
}
},
child: const Text('确定'),
const Text('举报理由(单选,非必选)'),
Builder(
builder: (context) => RadioGroup<int>(
onChanged: (v) {
reasonV2 = v;
(context as Element).markNeedsBuild();
},
groupValue: reasonV2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(
5,
(index) => RadioListTile<int>(
toggleable: true,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsets.only(left: 4),
dense: true,
value: index,
title: Text(
const ['色情低俗', '不实信息', '违禁', '人身攻击', '赌博诈骗'][index],
),
),
),
),
),
),
],
),
],
),
);
}
}
Widget _checkBoxWidget(
bool defValue,
ValueChanged onChanged,
String title,
) {
return InkWell(
onTap: () => onChanged(!defValue),
child: Row(
children: [
Checkbox(
value: defValue,
onChanged: onChanged,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
Text(title),
],
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(color: theme.colorScheme.outline),
),
),
TextButton(
onPressed: () async {
if (reason.isEmpty) {
SmartDialog.showToast('至少选择一项作为举报内容');
} else {
Get.back();
var result = await MemberHttp.reportMember(
mid,
reason: reason.join(','),
reasonV2: reasonV2 != null ? reasonV2! + 1 : null,
);
if (result['msg'] is String && result['msg'].isNotEmpty) {
SmartDialog.showToast(result['msg']);
} else {
SmartDialog.showToast('举报失败');
}
}
},
child: const Text('确定'),
),
],
);
},
);
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
@@ -7,7 +8,7 @@ class MarqueeText extends StatelessWidget {
final TextStyle? style;
final double spacing;
final double velocity;
final MarqueeController? controller;
final ContextSingleTicker? provider;
const MarqueeText(
this.text, {
@@ -15,7 +16,7 @@ class MarqueeText extends StatelessWidget {
this.style,
this.spacing = 0,
this.velocity = 25,
this.controller,
this.provider,
});
@override
@@ -23,7 +24,7 @@ class MarqueeText extends StatelessWidget {
return NormalMarquee(
velocity: velocity,
spacing: spacing,
controller: controller,
provider: provider,
child: Text(
text,
style: style,
@@ -39,7 +40,7 @@ abstract class Marquee extends SingleChildRenderObjectWidget {
final Clip clipBehavior;
final double spacing;
final double velocity;
final MarqueeController? controller;
final ContextSingleTicker? provider;
const Marquee({
super.key,
@@ -48,7 +49,7 @@ abstract class Marquee extends SingleChildRenderObjectWidget {
this.direction = Axis.horizontal,
this.clipBehavior = Clip.hardEdge,
this.spacing = 0,
this.controller,
this.provider,
});
@override
@@ -61,6 +62,10 @@ abstract class Marquee extends SingleChildRenderObjectWidget {
..clipBehavior = clipBehavior
..velocity = velocity
..spacing = spacing;
if (provider != null) {
renderObject.provider = provider!;
}
}
}
@@ -72,7 +77,7 @@ class NormalMarquee extends Marquee {
super.direction,
super.clipBehavior,
super.spacing,
super.controller,
super.provider,
});
@override
@@ -81,7 +86,7 @@ class NormalMarquee extends Marquee {
velocity: velocity,
clipBehavior: clipBehavior,
spacing: spacing,
controller: controller,
provider: provider ?? ContextSingleTicker(context),
);
}
@@ -93,6 +98,7 @@ class BounceMarquee extends Marquee {
super.direction,
super.clipBehavior,
super.spacing,
super.provider,
});
@override
@@ -101,6 +107,7 @@ class BounceMarquee extends Marquee {
velocity: velocity,
clipBehavior: clipBehavior,
spacing: spacing,
provider: provider ?? ContextSingleTicker(context),
);
}
@@ -111,16 +118,15 @@ abstract class MarqueeRender extends RenderBox
required double velocity,
required double spacing,
required this.clipBehavior,
this.controller,
}) : _spacing = spacing,
required ContextSingleTicker provider,
}) : _ticker = provider,
_spacing = spacing,
_velocity = velocity,
_direction = direction,
assert(spacing.isFinite && !spacing.isNaN);
Clip clipBehavior;
MarqueeController? controller;
Axis _direction;
Axis get direction => _direction;
set direction(Axis value) {
@@ -129,12 +135,26 @@ abstract class MarqueeRender extends RenderBox
markNeedsLayout();
}
ContextSingleTicker _ticker;
set provider(ContextSingleTicker value) {
if (_ticker == value) return;
if (_ticker._ticker != null) {
if (value._ticker != null) {
value._ticker!.absorbTicker(_ticker._ticker!);
} else {
value.createTicker(_onTick);
}
}
_ticker.cancel();
_ticker = value;
}
double _velocity;
set velocity(double value) {
if (_velocity == value) return;
_velocity = value;
_simulation = _simulation?.copyWith(initialValue: _delta, velocity: value);
controller?.reset();
_ticker.reset();
}
double _spacing;
@@ -149,7 +169,7 @@ abstract class MarqueeRender extends RenderBox
addSize: value - _spacing,
);
_spacing = value;
controller?.reset();
_ticker.reset();
}
double _delta = 0;
@@ -160,14 +180,14 @@ abstract class MarqueeRender extends RenderBox
}
@override
void detach() {
controller?.dispose();
super.detach();
void attach(PipelineOwner owner) {
super.attach(owner);
_ticker.updateTicker();
}
@override
void dispose() {
controller?.dispose();
_ticker.cancel();
super.dispose();
}
@@ -203,11 +223,9 @@ abstract class MarqueeRender extends RenderBox
if (_distance > 0) {
updateSize();
(controller ??= MarqueeController())
..ticker ??= Ticker(_onTick)
..initStart();
_ticker.createTicker(_onTick);
} else {
controller?.dispose();
_ticker.cancel();
}
}
@@ -237,6 +255,7 @@ class _BounceMarqueeRender extends MarqueeRender {
required super.velocity,
required super.clipBehavior,
required super.spacing,
required super.provider,
});
@override
@@ -278,7 +297,7 @@ class _NormalMarqueeRender extends MarqueeRender {
required super.velocity,
required super.clipBehavior,
required super.spacing,
super.controller,
required super.provider,
});
@override
@@ -375,44 +394,56 @@ class _MarqueeSimulation extends Simulation {
);
}
extension on Ticker {
class ContextSingleTicker {
Ticker? _ticker;
BuildContext context;
ContextSingleTicker(this.context);
void createTicker(TickerCallback onTick) {
assert(() {
if (_ticker == null) {
return true;
}
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'$runtimeType is a SingleTickerProviderStateMixin but multiple tickers were created.',
),
ErrorDescription(
'A SingleTickerProviderStateMixin can only be used as a TickerProvider once.',
),
ErrorHint(
'If a State is used for multiple AnimationController objects, or if it is passed to other '
'objects and those objects might use it more than one time in total, then instead of '
'mixing in a SingleTickerProviderStateMixin, use a regular TickerProviderStateMixin.',
),
]);
}());
_ticker = Ticker(
onTick,
debugLabel: kDebugMode ? 'created by ${describeIdentity(this)}' : null,
)..start();
_tickerModeNotifier = TickerMode.getNotifier(context)
..addListener(updateTicker);
updateTicker(); // Sets _ticker.mute correctly.
}
void reset() {
this
..stop()
_ticker
?..stop()
..start();
}
}
class MarqueeController {
MarqueeController({this.autoStart = true});
bool autoStart;
Ticker? ticker;
void initStart() {
if (autoStart) {
start();
}
}
void start() {
if (ticker != null) {
if (!ticker!.isTicking) {
ticker!.start();
}
}
}
void stop() {
ticker?.stop();
}
void reset() {
ticker?.reset();
}
void dispose() {
ticker?.dispose();
ticker = null;
}
void cancel() {
_ticker?.dispose();
_ticker = null;
_tickerModeNotifier?.removeListener(updateTicker);
_tickerModeNotifier = null;
}
ValueListenable<bool>? _tickerModeNotifier;
void updateTicker() => _ticker?.muted = !_tickerModeNotifier!.value;
set muted(bool value) => _ticker?.muted = value;
}

View File

@@ -1,43 +1,88 @@
import 'package:flutter/material.dart';
class RadioWidget<T> extends StatelessWidget {
class RadioWidget<T> extends StatefulWidget {
final T value;
final T? groupValue;
final ValueChanged<T?> onChanged;
final String title;
final bool tristate;
final EdgeInsetsGeometry? padding;
final MainAxisSize mainAxisSize;
const RadioWidget({
super.key,
required this.value,
this.groupValue,
required this.onChanged,
required this.title,
this.tristate = false,
this.padding,
this.mainAxisSize = MainAxisSize.min,
});
Widget _child() => Row(
children: [
Radio<T>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
Text(title),
],
);
@override
State<RadioWidget<T>> createState() => RadioWidgetState<T>();
}
class RadioWidgetState<T> extends State<RadioWidget<T>> with RadioClient<T> {
late final _RadioRegistry<T> _radioRegistry = _RadioRegistry<T>(this);
@override
final focusNode = FocusNode();
@override
T get radioValue => widget.value;
bool get checked => radioValue == registry!.groupValue;
@override
bool get tristate => widget.tristate;
@override
void dispose() {
registry = null;
focusNode.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
registry = RadioGroup.maybeOf(context);
assert(registry != null);
}
void _handleTap() {
if (checked) {
if (tristate) registry!.onChanged(null);
return;
}
registry!.onChanged(radioValue);
}
@override
Widget build(BuildContext context) {
final child = Row(
mainAxisSize: widget.mainAxisSize,
children: [
Focus(
parentNode: focusNode,
canRequestFocus: false,
skipTraversal: true,
includeSemantics: true,
descendantsAreFocusable: false,
descendantsAreTraversable: false,
child: Radio<T>(
value: radioValue,
groupRegistry: _radioRegistry,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
Text(widget.title),
],
);
return InkWell(
onTap: () => onChanged(value),
child: padding != null
? Padding(
padding: padding!,
child: _child(),
)
: _child(),
onTap: _handleTap,
focusNode: focusNode,
child: widget.padding == null
? child
: Padding(padding: widget.padding!, child: child),
);
}
}
@@ -45,16 +90,12 @@ class RadioWidget<T> extends StatelessWidget {
class WrapRadioOptionsGroup<T> extends StatelessWidget {
final String groupTitle;
final Map<T, String> options;
final T? selectedValue;
final ValueChanged<T?> onChanged;
final EdgeInsetsGeometry? itemPadding;
const WrapRadioOptionsGroup({
super.key,
required this.groupTitle,
required this.options,
required this.selectedValue,
required this.onChanged,
this.itemPadding,
});
@@ -75,14 +116,10 @@ class WrapRadioOptionsGroup<T> extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
children: options.entries.map((entry) {
return IntrinsicWidth(
child: RadioWidget<T>(
value: entry.key,
groupValue: selectedValue,
onChanged: onChanged,
title: entry.value,
padding: itemPadding ?? const EdgeInsets.only(right: 10),
),
return RadioWidget<T>(
value: entry.key,
title: entry.value,
padding: itemPadding ?? const EdgeInsets.only(right: 10),
);
}).toList(),
),
@@ -91,3 +128,27 @@ class WrapRadioOptionsGroup<T> extends StatelessWidget {
);
}
}
/// A registry to controls internal [Radio] and hides it from [RadioGroup]
/// ancestor.
///
/// [RadioListTile] implements the [RadioClient] directly to register to
/// [RadioGroup] ancestor. Therefore, it has to hide the internal [Radio] from
/// participate in the [RadioGroup] ancestor.
class _RadioRegistry<T> extends RadioGroupRegistry<T> {
_RadioRegistry(this.state);
final RadioWidgetState<T> state;
@override
T? get groupValue => state.registry!.groupValue;
@override
ValueChanged<T?> get onChanged => state.registry!.onChanged;
@override
void registerClient(RadioClient<T> radio) {}
@override
void unregisterClient(RadioClient<T> radio) {}
}