Files
2026-07-24 23:11:20 +08:00

770 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class JzSheetHeader extends StatelessWidget {
final String title;
final String? subtitle;
const JzSheetHeader({super.key, required this.title, this.subtitle});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 38,
height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 18),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(2),
),
),
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
if (subtitle != null) ...[
SizedBox(height: 6),
Text(
subtitle!,
textAlign: TextAlign.center,
style: TextStyle(
color: context.jz.text2,
fontSize: 12.5,
height: 1.5,
),
),
],
],
);
}
}
class JzActionButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final bool secondary;
final bool destructive;
final bool loading;
final Widget? icon;
const JzActionButton({
super.key,
required this.label,
required this.onPressed,
this.secondary = false,
this.destructive = false,
this.loading = false,
this.icon,
});
@override
Widget build(BuildContext context) {
final foreground = destructive ? AppTheme.red : AppTheme.primaryDeep;
final indicator = loading
? SizedBox(
width: 17,
height: 17,
child: CircularProgressIndicator(strokeWidth: 2, color: foreground),
)
: icon ?? const SizedBox.shrink();
if (secondary) {
return OutlinedButton.icon(
onPressed: loading ? null : onPressed,
icon: indicator,
label: Text(label),
style: OutlinedButton.styleFrom(
foregroundColor: foreground,
backgroundColor: destructive
? context.jz.expenseBackground
: context.jz.card,
side: BorderSide(
color: destructive
? AppTheme.red.withValues(alpha: 0.25)
: context.jz.line,
),
),
);
}
return FilledButton.icon(
onPressed: loading ? null : onPressed,
icon: loading
? SizedBox(
width: 17,
height: 17,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: indicator,
label: Text(label),
style: FilledButton.styleFrom(
backgroundColor: destructive ? AppTheme.red : AppTheme.primary,
),
);
}
}
class JzOption<T> {
final T value;
final String label;
final String? subtitle;
final Widget? leading;
const JzOption({
required this.value,
required this.label,
this.subtitle,
this.leading,
});
}
Future<T?> showJzOptionSheet<T>(
BuildContext context, {
required String title,
String? subtitle,
required List<JzOption<T>> options,
T? selected,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.72,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: JzSheetHeader(title: title, subtitle: subtitle),
),
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
itemCount: options.length,
separatorBuilder: (_, __) => SizedBox(height: 7),
itemBuilder: (_, index) {
final option = options[index];
final active = option.value == selected;
return Semantics(
selected: active,
button: true,
child: InkWell(
onTap: () => Navigator.pop(sheetContext, option.value),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: active
? context.jz.primaryBackground
: context.jz.background,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: active ? AppTheme.primary : context.jz.line,
),
),
child: Row(
children: [
if (option.leading != null) ...[
option.leading!,
SizedBox(width: 11),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.label,
style: TextStyle(
fontSize: 13.5,
fontWeight: active
? FontWeight.w700
: FontWeight.w600,
color: active
? AppTheme.primaryDeep
: context.jz.text,
),
),
if (option.subtitle != null) ...[
SizedBox(height: 3),
Text(
option.subtitle!,
style: TextStyle(
fontSize: 11,
color: context.jz.text3,
),
),
],
],
),
),
if (active)
Icon(
Icons.check_rounded,
color: AppTheme.primary,
size: 20,
),
],
),
),
),
);
},
),
),
],
),
),
),
);
}
Future<bool> showJzConfirmSheet(
BuildContext context, {
required String title,
required String message,
String confirmLabel = '确定',
String cancelLabel = '取消',
bool destructive = false,
Widget? content,
}) async {
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom,
),
child: Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
JzSheetHeader(title: title, subtitle: message),
if (content != null) ...[SizedBox(height: 14), content],
SizedBox(height: 20),
Row(
children: [
Expanded(
child: JzActionButton(
label: cancelLabel,
secondary: true,
onPressed: () => Navigator.pop(sheetContext, false),
),
),
SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: confirmLabel,
destructive: destructive,
onPressed: () => Navigator.pop(sheetContext, true),
),
),
],
),
],
),
),
),
),
);
return result == true;
}
Future<String?> showJzTextInputSheet(
BuildContext context, {
required String title,
required String label,
String? subtitle,
String? initialValue,
bool obscureText = false,
int? maxLength,
String confirmLabel = '确定',
}) async {
final controller = TextEditingController(text: initialValue);
final result = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom,
),
child: Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
JzSheetHeader(title: title, subtitle: subtitle),
SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
obscureText: obscureText,
maxLength: maxLength,
textInputAction: TextInputAction.done,
onSubmitted: (_) =>
Navigator.pop(sheetContext, controller.text.trim()),
decoration: InputDecoration(labelText: label),
),
SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: JzActionButton(
label: confirmLabel,
onPressed: () =>
Navigator.pop(sheetContext, controller.text.trim()),
),
),
],
),
),
),
),
);
controller.dispose();
return result;
}
Future<DateTime?> showJzDateTimeSheet(
BuildContext context, {
required DateTime initial,
DateTime? firstDate,
DateTime? lastDate,
String title = '选择日期和时间',
}) {
final minimum = firstDate ?? DateTime(2000);
final maximum = lastDate ?? ShanghaiTime.now.add(const Duration(days: 365));
return showModalBottomSheet<DateTime>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
var date = DateUtils.dateOnly(initial);
var hour = initial.hour;
var minute = initial.minute;
return StatefulBuilder(
builder: (context, setSheetState) => SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.86,
),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
JzSheetHeader(title: title),
Flexible(
child: CalendarDatePicker(
initialDate: date.isBefore(minimum)
? minimum
: date.isAfter(maximum)
? maximum
: date,
firstDate: minimum,
lastDate: maximum,
onDateChanged: (value) => setSheetState(() => date = value),
),
),
Container(
height: 92,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: context.jz.background,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line),
),
child: Row(
children: [
Text(
'时间',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
Spacer(),
_TimeWheel(
value: hour,
count: 24,
onChanged: (value) => setSheetState(() => hour = value),
),
Text(
':',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
_TimeWheel(
value: minute,
count: 60,
onChanged: (value) =>
setSheetState(() => minute = value),
),
],
),
),
SizedBox(height: 14),
Row(
children: [
Expanded(
child: JzActionButton(
label: '取消',
secondary: true,
onPressed: () => Navigator.pop(sheetContext),
),
),
SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: '完成',
onPressed: () => Navigator.pop(
sheetContext,
DateTime(
date.year,
date.month,
date.day,
hour,
minute,
),
),
),
),
],
),
],
),
),
),
);
},
);
}
class JzSegmentedControl<T> extends StatelessWidget {
final T value;
final List<JzOption<T>> options;
final ValueChanged<T>? onChanged;
const JzSegmentedControl({
super.key,
required this.value,
required this.options,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: options.map((option) {
final selected = option.value == value;
return Expanded(
child: Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onChanged == null
? null
: () => onChanged!(option.value),
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: selected ? context.jz.card : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: selected ? context.jz.line : Colors.transparent,
),
),
child: Text(
option.label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected ? FontWeight.w800 : FontWeight.w500,
color: selected
? value == 'income'
? AppTheme.primaryDeep
: context.jz.text
: context.jz.text2,
),
),
),
),
),
);
}).toList(),
),
);
}
}
class JzSwitchTile extends StatelessWidget {
final bool value;
final String title;
final String? subtitle;
final ValueChanged<bool>? onChanged;
const JzSwitchTile({
super.key,
required this.value,
required this.title,
this.subtitle,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Semantics(
toggled: value,
button: true,
label: title,
child: InkWell(
onTap: onChanged == null ? null : () => onChanged!(!value),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
if (subtitle != null) ...[
SizedBox(height: 3),
Text(
subtitle!,
style: TextStyle(
fontSize: 11.5,
color: context.jz.text3,
),
),
],
],
),
),
SizedBox(width: 12),
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 46,
height: 27,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: value ? context.jz.primaryBackground : context.jz.line,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: value ? AppTheme.primary : context.jz.text3,
),
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: value
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
width: 19,
height: 19,
decoration: BoxDecoration(
color: value ? AppTheme.primary : context.jz.card,
shape: BoxShape.circle,
),
),
),
),
],
),
),
),
);
}
}
class JzSlider extends StatelessWidget {
final double value;
final double min;
final double max;
final Color color;
final ValueChanged<double>? onChanged;
const JzSlider({
super.key,
required this.value,
this.min = 0,
this.max = 100,
this.color = AppTheme.primary,
required this.onChanged,
});
double get _fraction =>
max <= min ? 0 : ((value - min) / (max - min)).clamp(0.0, 1.0);
@override
Widget build(BuildContext context) {
void update(double x, double width) {
if (onChanged == null || width <= 0) return;
final fraction = (x / width).clamp(0.0, 1.0);
onChanged!(min + (max - min) * fraction);
}
return Semantics(
slider: true,
value: value.round().toString(),
increasedValue: (value + 5).clamp(min, max).round().toString(),
decreasedValue: (value - 5).clamp(min, max).round().toString(),
onIncrease: onChanged == null
? null
: () => onChanged!((value + 5).clamp(min, max)),
onDecrease: onChanged == null
? null
: () => onChanged!((value - 5).clamp(min, max)),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final thumbLeft = (width - 24) * _fraction;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: onChanged == null
? null
: (details) => update(details.localPosition.dx, width),
onHorizontalDragUpdate: onChanged == null
? null
: (details) => update(details.localPosition.dx, width),
child: SizedBox(
height: 36,
child: Stack(
alignment: Alignment.centerLeft,
children: [
Container(
height: 6,
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(3),
),
),
FractionallySizedBox(
widthFactor: _fraction,
child: Container(
height: 6,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
),
),
Positioned(
left: thumbLeft,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: context.jz.card,
shape: BoxShape.circle,
border: Border.all(color: color, width: 2),
boxShadow: const [
BoxShadow(
color: Color(0x1A191F26),
blurRadius: 5,
offset: Offset(0, 2),
),
],
),
),
),
],
),
),
);
},
),
);
}
}
class _TimeWheel extends StatelessWidget {
final int value;
final int count;
final ValueChanged<int> onChanged;
const _TimeWheel({
required this.value,
required this.count,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 52,
child: ListWheelScrollView.useDelegate(
controller: FixedExtentScrollController(initialItem: value),
itemExtent: 34,
physics: const FixedExtentScrollPhysics(),
onSelectedItemChanged: (index) {
HapticFeedback.selectionClick();
onChanged(index);
},
childDelegate: ListWheelChildBuilderDelegate(
childCount: count,
builder: (_, index) => Center(
child: Text(
index.toString().padLeft(2, '0'),
style: TextStyle(
fontSize: index == value ? 18 : 13,
fontWeight: index == value ? FontWeight.w800 : FontWeight.w500,
color: index == value ? AppTheme.primaryDeep : context.jz.text3,
),
),
),
),
),
);
}
}