Initial project import
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/sync_service.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
|
||||
enum AiAccessState { guest, reauthenticate, cloudDisabled }
|
||||
|
||||
AiAccessState? currentAiAccessState() {
|
||||
final session = SessionStore.instance;
|
||||
if (session.isGuest) return AiAccessState.guest;
|
||||
if (session.needsReauth) return AiAccessState.reauthenticate;
|
||||
if (!session.cloudSyncEnabled) return AiAccessState.cloudDisabled;
|
||||
return null;
|
||||
}
|
||||
|
||||
class AiAccessGate extends StatefulWidget {
|
||||
final AiAccessState state;
|
||||
final Future<void> Function()? onAction;
|
||||
|
||||
const AiAccessGate({super.key, required this.state, this.onAction});
|
||||
|
||||
@override
|
||||
State<AiAccessGate> createState() => _AiAccessGateState();
|
||||
}
|
||||
|
||||
class _AiAccessGateState extends State<AiAccessGate> {
|
||||
bool _busy = false;
|
||||
|
||||
String get _title => switch (widget.state) {
|
||||
AiAccessState.guest => '登录后使用 AI 助手',
|
||||
AiAccessState.reauthenticate => '登录状态已过期',
|
||||
AiAccessState.cloudDisabled => 'AI 功能需要云连接',
|
||||
};
|
||||
|
||||
String get _message => switch (widget.state) {
|
||||
AiAccessState.guest => '游客账单会继续安全保存在本机。登录后即可使用 AI 聊天、语音解析和图片识别。',
|
||||
AiAccessState.reauthenticate => '本地记账不受影响。重新登录后可以继续使用 AI 和云同步。',
|
||||
AiAccessState.cloudDisabled => '当前账号仅使用本地数据。开启云同步后才能发送 AI 消息。',
|
||||
};
|
||||
|
||||
String get _actionLabel => switch (widget.state) {
|
||||
AiAccessState.guest => '登录后使用',
|
||||
AiAccessState.reauthenticate => '重新登录',
|
||||
AiAccessState.cloudDisabled => '开启云同步',
|
||||
};
|
||||
|
||||
Future<void> _act() async {
|
||||
if (_busy) return;
|
||||
if (widget.onAction != null) {
|
||||
await widget.onAction!();
|
||||
return;
|
||||
}
|
||||
if (widget.state != AiAccessState.cloudDisabled) {
|
||||
if (mounted) {
|
||||
context.go(
|
||||
Uri(path: '/login', queryParameters: {'notice': _title}).toString(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await SessionStore.instance.setCloudSyncEnabled(true);
|
||||
await SyncService.instance.run();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 62,
|
||||
height: 62,
|
||||
decoration: BoxDecoration(
|
||||
color: context.jz.aiBackground,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcons.icon(
|
||||
AppIcons.sparkle,
|
||||
size: 29,
|
||||
color: AppTheme.ai,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18),
|
||||
Text(
|
||||
_title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
||||
),
|
||||
SizedBox(height: 9),
|
||||
Text(
|
||||
_message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: context.jz.text2,
|
||||
fontSize: 12.5,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 22),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: JzActionButton(
|
||||
label: _actionLabel,
|
||||
loading: _busy,
|
||||
onPressed: _busy ? null : _act,
|
||||
),
|
||||
),
|
||||
if (widget.state == AiAccessState.guest) ...[
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'无需登录也可以继续手动记账、管理预算和查看统计',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class CategoryIconMeta {
|
||||
final String key;
|
||||
final String label;
|
||||
final String group;
|
||||
|
||||
const CategoryIconMeta(this.key, this.label, this.group);
|
||||
}
|
||||
|
||||
/// 全套自绘 SVG 图标(来自 design/ui-mockup.html v0.5)
|
||||
/// 使用方式:AppIcons.cat → SvgPicture.asset(AppIcons.cat, colorFilter: ..., width: 24)
|
||||
class AppIcons {
|
||||
AppIcons._();
|
||||
|
||||
static const _p = 'assets/icons/';
|
||||
|
||||
// AI 形象
|
||||
static const cat = '${_p}i-cat.svg';
|
||||
static const dog = '${_p}i-dog.svg';
|
||||
static const robot = '${_p}i-robot.svg';
|
||||
|
||||
// 底部导航
|
||||
static const home = '${_p}i-home.svg';
|
||||
static const chart = '${_p}i-chart.svg';
|
||||
static const chat = '${_p}i-chat.svg';
|
||||
static const user = '${_p}i-user.svg';
|
||||
static const plus = '${_p}i-plus.svg';
|
||||
|
||||
// 分类图标
|
||||
static const food = '${_p}i-food.svg';
|
||||
static const cup = '${_p}i-cup.svg';
|
||||
static const cart = '${_p}i-cart.svg';
|
||||
static const metro = '${_p}i-metro.svg';
|
||||
static const house = '${_p}i-house.svg';
|
||||
static const game = '${_p}i-game.svg';
|
||||
static const pill = '${_p}i-pill.svg';
|
||||
static const book = '${_p}i-book.svg';
|
||||
static const shirt = '${_p}i-shirt.svg';
|
||||
static const gift = '${_p}i-gift.svg';
|
||||
static const plane = '${_p}i-plane.svg';
|
||||
static const money = '${_p}i-money.svg';
|
||||
static const tag = '${_p}i-tag.svg';
|
||||
|
||||
// 扩展
|
||||
static const wallet = '${_p}i-wallet.svg';
|
||||
static const target = '${_p}i-target.svg';
|
||||
static const sparkle = '${_p}i-sparkle.svg';
|
||||
static const briefcase = '${_p}i-briefcase.svg';
|
||||
static const cardIcon = '${_p}i-card.svg';
|
||||
static const search = '${_p}i-search.svg';
|
||||
static const bell = '${_p}i-bell.svg';
|
||||
static const eye = '${_p}i-eye.svg';
|
||||
static const gear = '${_p}i-gear.svg';
|
||||
static const mic = '${_p}i-mic.svg';
|
||||
static const smile = '${_p}i-smile.svg';
|
||||
static const camera = '${_p}i-camera.svg';
|
||||
static const fire = '${_p}i-fire.svg';
|
||||
static const chevronDown = '${_p}i-chev-d.svg';
|
||||
static const chevronRight = '${_p}i-chev-r.svg';
|
||||
static const close = '${_p}i-close.svg';
|
||||
static const check = '${_p}i-check.svg';
|
||||
static const exportIcon = '${_p}i-export.svg';
|
||||
static const cloud = '${_p}i-cloud.svg';
|
||||
static const swap = '${_p}i-swap.svg';
|
||||
static const receipt = '${_p}i-receipt.svg';
|
||||
static const edit = '${_p}i-edit.svg';
|
||||
static const trash = '${_p}i-trash.svg';
|
||||
static const drag = '${_p}i-drag.svg';
|
||||
static const offline = '${_p}i-offline.svg';
|
||||
static const wave = '${_p}i-wave.svg';
|
||||
static const scan = '${_p}i-scan.svg';
|
||||
|
||||
// 扩展分类图标
|
||||
static const car = '${_p}i-car.svg';
|
||||
static const baby = '${_p}i-baby.svg';
|
||||
static const pet = '${_p}i-pet.svg';
|
||||
static const phone = '${_p}i-phone.svg';
|
||||
static const wifi = '${_p}i-wifi.svg';
|
||||
static const sport = '${_p}i-sport.svg';
|
||||
static const beauty = '${_p}i-beauty.svg';
|
||||
static const insurance = '${_p}i-shield.svg';
|
||||
static const tax = '${_p}i-tax.svg';
|
||||
static const rent = '${_p}i-rent.svg';
|
||||
static const refund = '${_p}i-refund.svg';
|
||||
static const interest = '${_p}i-interest.svg';
|
||||
|
||||
// 映射 iconKey → asset path
|
||||
static const keyMap = <String, String>{
|
||||
'food': food,
|
||||
'cup': cup,
|
||||
'cart': cart,
|
||||
'metro': metro,
|
||||
'house': house,
|
||||
'game': game,
|
||||
'pill': pill,
|
||||
'book': book,
|
||||
'shirt': shirt,
|
||||
'gift': gift,
|
||||
'plane': plane,
|
||||
'tag': tag,
|
||||
'money': money,
|
||||
'briefcase': briefcase,
|
||||
'chart': chart,
|
||||
'card': cardIcon,
|
||||
'sparkle': sparkle,
|
||||
'wallet': wallet,
|
||||
'cat': cat,
|
||||
'dog': dog,
|
||||
'robot': robot,
|
||||
'target': target,
|
||||
'camera': camera,
|
||||
'receipt': receipt,
|
||||
'car': car,
|
||||
'baby': baby,
|
||||
'pet': pet,
|
||||
'phone': phone,
|
||||
'wifi': wifi,
|
||||
'sport': sport,
|
||||
'beauty': beauty,
|
||||
'insurance': insurance,
|
||||
'tax': tax,
|
||||
'rent': rent,
|
||||
'refund': refund,
|
||||
'interest': interest,
|
||||
};
|
||||
|
||||
static const categoryCatalog = <CategoryIconMeta>[
|
||||
CategoryIconMeta('food', '餐饮', '日常生活'),
|
||||
CategoryIconMeta('cup', '饮品', '日常生活'),
|
||||
CategoryIconMeta('cart', '购物', '日常生活'),
|
||||
CategoryIconMeta('house', '住房', '日常生活'),
|
||||
CategoryIconMeta('rent', '房租', '日常生活'),
|
||||
CategoryIconMeta('metro', '公交地铁', '交通出行'),
|
||||
CategoryIconMeta('car', '汽车', '交通出行'),
|
||||
CategoryIconMeta('plane', '旅行', '交通出行'),
|
||||
CategoryIconMeta('phone', '手机数码', '日常生活'),
|
||||
CategoryIconMeta('wifi', '网络通信', '日常生活'),
|
||||
CategoryIconMeta('shirt', '服饰', '日常生活'),
|
||||
CategoryIconMeta('beauty', '美容', '日常生活'),
|
||||
CategoryIconMeta('pill', '医疗', '健康成长'),
|
||||
CategoryIconMeta('sport', '运动', '健康成长'),
|
||||
CategoryIconMeta('book', '学习', '健康成长'),
|
||||
CategoryIconMeta('baby', '育儿', '健康成长'),
|
||||
CategoryIconMeta('pet', '宠物', '健康成长'),
|
||||
CategoryIconMeta('game', '娱乐', '休闲人情'),
|
||||
CategoryIconMeta('gift', '礼物人情', '休闲人情'),
|
||||
CategoryIconMeta('wallet', '钱包', '资金收入'),
|
||||
CategoryIconMeta('money', '工资', '资金收入'),
|
||||
CategoryIconMeta('briefcase', '工作兼职', '资金收入'),
|
||||
CategoryIconMeta('chart', '投资理财', '资金收入'),
|
||||
CategoryIconMeta('interest', '利息收益', '资金收入'),
|
||||
CategoryIconMeta('refund', '退款', '资金收入'),
|
||||
CategoryIconMeta('card', '银行卡', '资金收入'),
|
||||
CategoryIconMeta('insurance', '保险', '资金收入'),
|
||||
CategoryIconMeta('tax', '税费', '资金收入'),
|
||||
CategoryIconMeta('receipt', '票据账单', '其他'),
|
||||
CategoryIconMeta('camera', '摄影', '其他'),
|
||||
CategoryIconMeta('target', '目标', '其他'),
|
||||
CategoryIconMeta('sparkle', '奖励', '其他'),
|
||||
CategoryIconMeta('tag', '其他', '其他'),
|
||||
];
|
||||
|
||||
static String avatarAsset(String key) => keyMap[key] ?? cat;
|
||||
|
||||
/// 返回 SvgPicture widget
|
||||
static Widget icon(String asset, {double size = 24, Color? color}) {
|
||||
return SvgPicture.asset(
|
||||
asset,
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: color != null
|
||||
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 按 iconKey 返回 SvgPicture
|
||||
static Widget byKey(String key, {double size = 24, Color? color}) {
|
||||
return icon(keyMap[key] ?? tag, size: size, color: color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
|
||||
class AsyncErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
final Future<void> Function() onRetry;
|
||||
|
||||
const AsyncErrorView({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.cloud_off_outlined, size: 42, color: context.jz.text3),
|
||||
SizedBox(height: 14),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: context.jz.text2,
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: Icon(Icons.refresh, size: 18),
|
||||
label: Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
|
||||
class BrandLogo extends StatelessWidget {
|
||||
final double size;
|
||||
final bool framed;
|
||||
|
||||
const BrandLogo({super.key, this.size = 88, this.framed = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(size * 0.24),
|
||||
child: Image.asset(
|
||||
'assets/branding/jizhi-app-icon-centered.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.high,
|
||||
semanticLabel: '记之',
|
||||
),
|
||||
);
|
||||
if (!framed) return image;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.jz.card,
|
||||
borderRadius: BorderRadius.circular(size * 0.26),
|
||||
border: Border.all(color: context.jz.line),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.jz.text.withValues(alpha: 0.08),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: image,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
|
||||
class CategoryColorMeta {
|
||||
final String key;
|
||||
final String label;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
|
||||
const CategoryColorMeta({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
});
|
||||
}
|
||||
|
||||
const categoryColorCatalog = <CategoryColorMeta>[
|
||||
CategoryColorMeta(
|
||||
key: 'mint',
|
||||
label: '薄荷',
|
||||
background: Color(0xFFE3F7F0),
|
||||
foreground: Color(0xFF008E69),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'teal',
|
||||
label: '青绿',
|
||||
background: Color(0xFFE1F3F1),
|
||||
foreground: Color(0xFF087F72),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'aqua',
|
||||
label: '水绿',
|
||||
background: Color(0xFFE4F6F5),
|
||||
foreground: Color(0xFF168C87),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'cyan',
|
||||
label: '青蓝',
|
||||
background: Color(0xFFE3F4F7),
|
||||
foreground: Color(0xFF187F91),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'sky',
|
||||
label: '天蓝',
|
||||
background: Color(0xFFE7F2FA),
|
||||
foreground: Color(0xFF327EAD),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'blue',
|
||||
label: '蓝色',
|
||||
background: Color(0xFFE8EFFB),
|
||||
foreground: Color(0xFF416FAE),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'navy',
|
||||
label: '海军蓝',
|
||||
background: Color(0xFFE8ECF3),
|
||||
foreground: Color(0xFF425A7B),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'indigo',
|
||||
label: '靛蓝',
|
||||
background: Color(0xFFEBEDFA),
|
||||
foreground: Color(0xFF5864B1),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'violet',
|
||||
label: '紫罗兰',
|
||||
background: Color(0xFFF0EBF8),
|
||||
foreground: Color(0xFF7656A8),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'plum',
|
||||
label: '梅紫',
|
||||
background: Color(0xFFF5EAF2),
|
||||
foreground: Color(0xFF94547E),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'orchid',
|
||||
label: '兰紫',
|
||||
background: Color(0xFFF6EAF4),
|
||||
foreground: Color(0xFFA45291),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'rose',
|
||||
label: '玫瑰',
|
||||
background: Color(0xFFF9E9EE),
|
||||
foreground: Color(0xFFB94E6B),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'coral',
|
||||
label: '珊瑚',
|
||||
background: Color(0xFFFBEAE6),
|
||||
foreground: Color(0xFFC45B49),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'red',
|
||||
label: '朱红',
|
||||
background: Color(0xFFFBE9E8),
|
||||
foreground: Color(0xFFC4473F),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'orange',
|
||||
label: '橙色',
|
||||
background: Color(0xFFFCEDE2),
|
||||
foreground: Color(0xFFBE681F),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'amber',
|
||||
label: '琥珀',
|
||||
background: Color(0xFFFAF0DB),
|
||||
foreground: Color(0xFFA66B0E),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'peach',
|
||||
label: '桃色',
|
||||
background: Color(0xFFFBEDE6),
|
||||
foreground: Color(0xFFB96B45),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'sand',
|
||||
label: '沙金',
|
||||
background: Color(0xFFF5F0E4),
|
||||
foreground: Color(0xFF8C7442),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'lime',
|
||||
label: '青柠',
|
||||
background: Color(0xFFF0F5E2),
|
||||
foreground: Color(0xFF71852D),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'olive',
|
||||
label: '橄榄',
|
||||
background: Color(0xFFEEF0E3),
|
||||
foreground: Color(0xFF68723C),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'forest',
|
||||
label: '森林',
|
||||
background: Color(0xFFE6F1E9),
|
||||
foreground: Color(0xFF39764A),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'slate',
|
||||
label: '石板',
|
||||
background: Color(0xFFEBEFF0),
|
||||
foreground: Color(0xFF5F7074),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'cocoa',
|
||||
label: '可可',
|
||||
background: Color(0xFFF1ECE8),
|
||||
foreground: Color(0xFF796154),
|
||||
),
|
||||
CategoryColorMeta(
|
||||
key: 'graphite',
|
||||
label: '石墨',
|
||||
background: Color(0xFFEDEEEE),
|
||||
foreground: Color(0xFF5D6261),
|
||||
),
|
||||
];
|
||||
|
||||
CategoryColorMeta categoryColorMeta(String? key) =>
|
||||
categoryColorCatalog.firstWhere(
|
||||
(item) => item.key == key,
|
||||
orElse: () => categoryColorCatalog.first,
|
||||
);
|
||||
|
||||
(Color bg, Color fg) categoryColors(String? colorKey) {
|
||||
final meta = categoryColorMeta(colorKey);
|
||||
return (meta.background, meta.foreground);
|
||||
}
|
||||
|
||||
class CategoryIconBox extends StatelessWidget {
|
||||
final String iconKey;
|
||||
final String? colorKey;
|
||||
final double size;
|
||||
|
||||
const CategoryIconBox({
|
||||
super.key,
|
||||
required this.iconKey,
|
||||
this.colorKey,
|
||||
this.size = 36,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (bg, fg) = categoryColors(colorKey);
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(size * 0.3),
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcons.byKey(iconKey, size: size * 0.5, color: fg),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget categoryIconSvg(
|
||||
String key, {
|
||||
double size = 24,
|
||||
Color? color,
|
||||
String? colorKey,
|
||||
}) {
|
||||
final (_, fg) = categoryColors(colorKey);
|
||||
return AppIcons.byKey(key, size: size, color: color ?? fg);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
|
||||
/// P24 离线状态:顶部提示条 + AI 入口/tab 控制
|
||||
class OfflineBar extends StatelessWidget {
|
||||
const OfflineBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
color: AppTheme.orange,
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcons.icon(AppIcons.offline, size: 16, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'当前无网络 · AI 功能暂不可用 · 手动记账正常',
|
||||
style: TextStyle(fontSize: 10.5, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测是否有网络连接(简化版:同时 ping 后端)
|
||||
class ConnectivityChecker {
|
||||
static bool _lastKnown = true;
|
||||
|
||||
static bool get isOnline => _lastKnown;
|
||||
|
||||
static Future<bool> check() async {
|
||||
// 简化实现:future 如果需要真正的网络检测,用 connectivity_plus 包
|
||||
// 当前返回上次已知状态
|
||||
return _lastKnown;
|
||||
}
|
||||
|
||||
static void setOffline() => _lastKnown = false;
|
||||
static void setOnline() => _lastKnown = true;
|
||||
}
|
||||
Reference in New Issue
Block a user