297 lines
8.5 KiB
Dart
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} 天前';
|
|
}
|