整个课程和数据UI重新设计版本
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
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} 天前';
|
||||
}
|
||||
Reference in New Issue
Block a user