Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -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,
),
),
),
),
),
);
}
}
+183
View File
@@ -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;
}