Files
English/kouyu_english/lib/features/profile/profile_widgets.dart
T

297 lines
8.5 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../widgets/app_widgets.dart';
/// Opens a "我的" sub page. Pushed routes sit above the shell, so each page
/// listens to [state] itself to stay current.
Future<void> openProfilePage(
BuildContext context, {
required AppState state,
required String title,
required WidgetBuilder builder,
}) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => AppPage(
appBar: AppBar(title: Text(title)),
child: ListenableBuilder(
listenable: state,
builder: (context, _) => builder(context),
),
),
),
);
/// A titled card whose rows are separated by dividers.
class MenuGroup extends StatelessWidget {
const MenuGroup({super.key, this.title, this.note, required this.children});
final String? title;
final String? note;
final List<Widget> children;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null || note != null)
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Text(
title!,
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
if (note != null)
Text(
note!,
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
SectionCard(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) Divider(height: 1, color: AppColors.line),
children[i],
],
],
),
),
],
);
}
/// One row of a [MenuGroup]: icon, title, optional current value and chevron.
class MenuTile extends StatelessWidget {
const MenuTile({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.value,
this.iconColor,
this.trailing,
this.onTap,
});
final IconData icon;
final String title;
final String? subtitle;
final String? value;
final Color? iconColor;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: Icon(icon, color: iconColor ?? AppColors.muted),
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle!),
trailing:
trailing ??
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (value != null)
Text(
value!,
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
if (onTap != null)
Icon(Icons.chevron_right, color: AppColors.muted),
],
),
onTap: onTap,
);
}
/// A [MenuTile]-aligned switch row.
class MenuSwitch extends StatelessWidget {
const MenuSwitch({
super.key,
required this.icon,
required this.title,
this.subtitle,
required this.value,
required this.onChanged,
});
final IconData icon;
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) => SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
secondary: Icon(icon, color: AppColors.muted),
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle!),
value: value,
activeThumbColor: AppColors.green,
onChanged: onChanged,
);
}
/// A labelled progress bar, e.g. "可使用 31 / 48".
class GoalProgressRow extends StatelessWidget {
const GoalProgressRow({
super.key,
required this.icon,
required this.title,
required this.note,
required this.value,
required this.goal,
});
final IconData icon;
final String title;
final String note;
final int value;
final int goal;
@override
Widget build(BuildContext context) {
final completed = value >= goal;
return Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Icon(icon, color: completed ? AppColors.green : AppColors.muted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
'$value / $goal',
style: TextStyle(color: AppColors.muted),
),
],
),
const SizedBox(height: 6),
ProgressBar(value: goal == 0 ? 0 : value / goal),
const SizedBox(height: 6),
Text(note, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
}
class ProgressBar extends StatelessWidget {
const ProgressBar({super.key, required this.value, this.background});
final double value;
final Color? background;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: value.clamp(0, 1).toDouble(),
minHeight: 6,
color: AppColors.green,
backgroundColor: background ?? AppColors.surfaceMuted,
),
);
}
/// A red row that asks for explicit acknowledgement before [onConfirm].
class DangerAction extends StatelessWidget {
const DangerAction({
super.key,
required this.label,
required this.message,
this.details,
this.acknowledgement,
required this.onConfirm,
});
final String label;
final String message;
final String? details;
/// When set, the confirm button stays disabled until this box is ticked.
final String? acknowledgement;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: const Icon(Icons.delete_forever_outlined, color: Colors.redAccent),
title: Text(label, style: const TextStyle(color: Colors.redAccent)),
onTap: () => showDialog<void>(
context: context,
builder: (context) {
var acknowledged = acknowledgement == null;
return StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(label),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message),
if (details != null) ...[
const SizedBox(height: 12),
Text(details!),
],
if (acknowledgement != null)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: acknowledged,
onChanged: (value) =>
setDialogState(() => acknowledged = value ?? false),
title: Text(acknowledgement!),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.redAccent,
),
onPressed: acknowledged
? () {
Navigator.pop(context);
onConfirm();
}
: null,
child: const Text('确认清除'),
),
],
),
);
},
),
);
}
String relativeTime(DateTime time) {
final difference = DateTime.now().difference(time);
if (difference.inMinutes < 1) return '刚刚';
if (difference.inHours < 1) return '${difference.inMinutes} 分钟前';
if (difference.inDays < 1) return '${difference.inHours} 小时前';
return '${difference.inDays} 天前';
}