first commit
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 金额输入过滤:仅数字 + 单小数点 + 最多 2 位小数
|
||||
///
|
||||
/// [allowNegative] 为 true 时允许开头的负号(账户余额可能为负)。
|
||||
/// 记账金额、预算不使用负号,保持默认 false。
|
||||
class AmountInputFormatter extends TextInputFormatter {
|
||||
AmountInputFormatter({this.allowNegative = false});
|
||||
|
||||
final bool allowNegative;
|
||||
|
||||
static final _positiveRegex = RegExp(r'^\d*\.?\d{0,2}$');
|
||||
static final _negativeRegex = RegExp(r'^-?\d*\.?\d{0,2}$');
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
final text = newValue.text;
|
||||
if (text.isEmpty) return newValue;
|
||||
final regex = allowNegative ? _negativeRegex : _positiveRegex;
|
||||
if (!regex.hasMatch(text)) return oldValue;
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// 金额展示与计算工具
|
||||
///
|
||||
/// 统一入口的意义:后端返回 double,可能出现 null / NaN / Infinity,
|
||||
/// 直接 toStringAsFixed 会抛异常或显示 "NaN",所以全部走这里兜底。
|
||||
library;
|
||||
|
||||
/// 金额展示:固定两位小数,非法值回退 '0.00'
|
||||
String formatAmount(double? value) {
|
||||
if (value == null || !value.isFinite) return '0.00';
|
||||
return value.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
/// 带符号金额:支出 '-¥12.00',收入 '+¥12.00'
|
||||
///
|
||||
/// 是否支出必须由调用方显式传入(用 bill.type 判断),
|
||||
/// 不要用金额正负判断——后端返回的 amount 恒为正数。
|
||||
String formatSignedAmount(double? amount, {required bool isExpense}) {
|
||||
final text = formatAmount(amount?.abs());
|
||||
return isExpense ? '-¥$text' : '+¥$text';
|
||||
}
|
||||
|
||||
/// 占比(0~100)。分母 <= 0 或非法时返回 0,避免除零与 NaN
|
||||
double percentOf(double part, double total) {
|
||||
if (!part.isFinite || !total.isFinite || total <= 0) return 0;
|
||||
final pct = part / total * 100;
|
||||
return pct.clamp(0, 100);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import '../models/bill.dart';
|
||||
|
||||
/// 按日期分组的账单
|
||||
class BillDayGroup {
|
||||
final String date; // yyyy-MM-dd
|
||||
final List<Bill> bills;
|
||||
final double expenseTotal; // type==1 的合计
|
||||
final double incomeTotal; // type==2 的合计
|
||||
|
||||
const BillDayGroup({
|
||||
required this.date,
|
||||
required this.bills,
|
||||
required this.expenseTotal,
|
||||
required this.incomeTotal,
|
||||
});
|
||||
}
|
||||
|
||||
/// 按 billDate 分组,并分别累计当日支出/收入。
|
||||
///
|
||||
/// 后端已按 `billDate DESC, createTime DESC` 排序,Dart 的 Map 保持插入顺序,
|
||||
/// 因此直接顺序遍历累积即可,不要再次排序(否则会打乱同日内的时间顺序)。
|
||||
List<BillDayGroup> groupBillsByDate(List<Bill> bills) {
|
||||
final grouped = <String, List<Bill>>{};
|
||||
for (final bill in bills) {
|
||||
grouped.putIfAbsent(bill.billDate, () => []).add(bill);
|
||||
}
|
||||
|
||||
return grouped.entries.map((entry) {
|
||||
var expense = 0.0;
|
||||
var income = 0.0;
|
||||
for (final bill in entry.value) {
|
||||
if (bill.isExpense) {
|
||||
expense += bill.absAmount;
|
||||
} else {
|
||||
income += bill.absAmount;
|
||||
}
|
||||
}
|
||||
return BillDayGroup(
|
||||
date: entry.key,
|
||||
bills: entry.value,
|
||||
expenseTotal: expense,
|
||||
incomeTotal: income,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// 与 uniApp 端 utils/crypto.js 完全一致的密码混淆算法(XOR + Base64)。
|
||||
/// 注意:仅防明文存储,非强加密。
|
||||
const String _secretKey = 'QIANGBAO_AI_JIZHANG_2024';
|
||||
|
||||
String encryptPassword(String password) {
|
||||
if (password.isEmpty) return '';
|
||||
final chars = password.codeUnits;
|
||||
final keyUnits = _secretKey.codeUnits;
|
||||
final encrypted = String.fromCharCodes([
|
||||
for (var i = 0; i < chars.length; i++) chars[i] ^ keyUnits[i % keyUnits.length],
|
||||
]);
|
||||
return base64Encode(utf8.encode(encrypted));
|
||||
}
|
||||
|
||||
String decryptPassword(String encrypted) {
|
||||
if (encrypted.isEmpty) return '';
|
||||
try {
|
||||
final decoded = utf8.decode(base64Decode(encrypted));
|
||||
final chars = decoded.codeUnits;
|
||||
final keyUnits = _secretKey.codeUnits;
|
||||
return String.fromCharCodes([
|
||||
for (var i = 0; i < chars.length; i++) chars[i] ^ keyUnits[i % keyUnits.length],
|
||||
]);
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// billDate 标准格式 yyyy-MM-dd
|
||||
String formatDate(DateTime date) => DateFormat('yyyy-MM-dd').format(date);
|
||||
|
||||
/// 解析后端日期字段,兼容 yyyy-MM-dd 与 yyyy-MM-ddTHH:mm:ss(LocalDateTime)两种格式
|
||||
String parseBillDate(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return formatDate(DateTime.now());
|
||||
final tIndex = raw.indexOf('T');
|
||||
if (tIndex > 0) return raw.substring(0, tIndex);
|
||||
final spaceIndex = raw.indexOf(' ');
|
||||
if (spaceIndex > 0) return raw.substring(0, spaceIndex);
|
||||
return raw;
|
||||
}
|
||||
|
||||
/// 解析后端日期为 DateTime,失败返回 null
|
||||
DateTime? tryParseDate(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
// ---------------- 月份 / 周 相关(首页、统计页用) ----------------
|
||||
|
||||
/// 当月 1 日 00:00,作为月份锚点使用
|
||||
DateTime monthStart(DateTime date) => DateTime(date.year, date.month, 1);
|
||||
|
||||
/// 当月最后一天 00:00。
|
||||
/// 用 day = 0 让 DateTime 自动回退到上个月最后一天,避免手算 28/30/31。
|
||||
DateTime monthEnd(DateTime date) => DateTime(date.year, date.month + 1, 0);
|
||||
|
||||
/// 月份平移(delta 为月数,可为负),跨年由 DateTime 自动归一
|
||||
DateTime shiftMonth(DateTime month, int delta) =>
|
||||
DateTime(month.year, month.month + delta, 1);
|
||||
|
||||
/// 本周一 00:00(以周一为一周起点,对齐 uniApp 的 getWeekStart)
|
||||
DateTime weekStart(DateTime date) {
|
||||
final d = DateTime(date.year, date.month, date.day);
|
||||
return d.subtract(Duration(days: d.weekday - DateTime.monday));
|
||||
}
|
||||
|
||||
/// 是否为同一个自然月
|
||||
bool isSameMonth(DateTime a, DateTime b) => a.year == b.year && a.month == b.month;
|
||||
|
||||
/// 月份标题:2026年9月
|
||||
String formatMonthTitle(DateTime month) => '${month.year}年${month.month}月';
|
||||
|
||||
const List<String> _weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
|
||||
/// 分组头日期:今天 / 昨天 / 09-21 周一
|
||||
///
|
||||
/// 注意:不要用 DateFormat('EEEE', 'zh_CN')——本项目未调用
|
||||
/// initializeDateFormatting,会抛 LocaleDataException,所以手写星期名。
|
||||
String formatGroupDate(String billDate) {
|
||||
final parsed = tryParseDate(billDate);
|
||||
if (parsed == null) return billDate;
|
||||
|
||||
final today = DateTime.now();
|
||||
final todayStart = DateTime(today.year, today.month, today.day);
|
||||
final target = DateTime(parsed.year, parsed.month, parsed.day);
|
||||
final diff = todayStart.difference(target).inDays;
|
||||
|
||||
if (diff == 0) return '今天';
|
||||
if (diff == 1) return '昨天';
|
||||
|
||||
final mm = target.month.toString().padLeft(2, '0');
|
||||
final dd = target.day.toString().padLeft(2, '0');
|
||||
return '$mm-$dd ${_weekdayNames[target.weekday - 1]}';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// 本地解析 JWT 的 exp 字段,判断是否已过期。
|
||||
///
|
||||
/// 目的:App 启动时立刻发现"登录已过期",不必先发一次请求才报错。
|
||||
/// 注意:只做本地时间判断,不校验签名——签名的真伪由后端负责。
|
||||
/// 返回 false(视为未过期)的条件:token 不是标准 JWT 格式或没有 exp 字段,
|
||||
/// 避免因格式差异把正常登录态误判为过期。
|
||||
bool isJwtExpired(String? token, {Duration leeway = const Duration(seconds: 30)}) {
|
||||
if (token == null || token.isEmpty) return true;
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) return false;
|
||||
|
||||
try {
|
||||
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
|
||||
final claims = jsonDecode(payload);
|
||||
if (claims is! Map) return false;
|
||||
final exp = claims['exp'];
|
||||
if (exp is! num) return false; // 无 exp 字段 → 不做本地判断
|
||||
final expireAt = DateTime.fromMillisecondsSinceEpoch(exp.toInt() * 1000);
|
||||
return expireAt.isBefore(DateTime.now().add(leeway));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/navigation/navigator_key.dart';
|
||||
|
||||
/// 统一提示封装(对齐 uni.showToast 的常用场景)
|
||||
void showToast(BuildContext context, String message, {bool error = false}) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: error ? const Color(0xFFB71C1C) : const Color(0xFF323232),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 全局提示:供没有 BuildContext 的地方调用(网络层、Provider)
|
||||
void showGlobalToast(String message, {bool error = false}) {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) showToast(context, message, error: error);
|
||||
}
|
||||
Reference in New Issue
Block a user