770 lines
25 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|