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 { 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 showJzOptionSheet( BuildContext context, { required String title, String? subtitle, required List> options, T? selected, }) { return showModalBottomSheet( 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 showJzConfirmSheet( BuildContext context, { required String title, required String message, String confirmLabel = '确定', String cancelLabel = '取消', bool destructive = false, Widget? content, }) async { final result = await showModalBottomSheet( 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 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( 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 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( 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 extends StatelessWidget { final T value; final List> options; final ValueChanged? 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? 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? 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 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, ), ), ), ), ), ); } }