Files
jizhi/frontend/lib/features/settings/budget_page.dart
T
2026-07-24 23:11:20 +08:00

1124 lines
43 KiB
Dart

import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
class BudgetPage extends StatefulWidget {
const BudgetPage({super.key});
@override
State<BudgetPage> createState() => _BudgetPageState();
}
class _BudgetPageState extends State<BudgetPage> {
BudgetsData? _data;
List<CategoryItem> _categories = [];
bool _loading = true;
bool _aiLoading = false;
DateTime _month = DateTime(ShanghaiTime.now.year, ShanghaiTime.now.month);
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final results = await Future.wait<dynamic>([
BudgetApi.get(_month.year, _month.month),
TxApi.categories('expense'),
]);
if (!mounted) return;
setState(() {
_data = results[0] as BudgetsData;
_categories = results[1] as List<CategoryItem>;
});
} catch (e) {
if (mounted) _showError(e);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _changeMonth(int offset) async {
setState(() {
_month = DateTime(_month.year, _month.month + offset);
_data = null;
});
await _load();
}
Future<void> _edit({
int? categoryId,
String? categoryName,
double? current,
bool recurring = false,
}) async {
final controller = TextEditingController(
text: current == null || current == 0 ? '' : current.toStringAsFixed(0),
);
var repeats = recurring;
final result =
await showModalBottomSheet<({double amount, bool recurring})>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => StatefulBuilder(
builder: (context, setSheetState) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).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: categoryId == null
? '总预算'
: '“' + (categoryName ?? '') + '”预算',
subtitle: '输入 0 可以删除当前预算',
),
SizedBox(height: 16),
TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
autofocus: true,
decoration: InputDecoration(
prefixText: '¥ ',
hintText: '输入预算金额',
),
),
SizedBox(height: 12),
InkWell(
onTap: () => setSheetState(() => repeats = !repeats),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 13,
vertical: 9,
),
decoration: BoxDecoration(
color: repeats
? context.jz.primaryBackground
: context.jz.background,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: repeats
? AppTheme.primary
: context.jz.line,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'每月自动续期',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 3),
Text(
'以后每个月沿用,可随时单独调整',
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
),
),
],
),
),
Switch(
value: repeats,
onChanged: (value) =>
setSheetState(() => repeats = value),
),
],
),
),
),
SizedBox(height: 18),
Row(
children: [
Expanded(
child: JzActionButton(
label: '取消',
secondary: true,
onPressed: () => Navigator.pop(sheetContext),
),
),
SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: '保存',
onPressed: () => Navigator.pop(sheetContext, (
amount:
double.tryParse(controller.text.trim()) ??
0,
recurring: repeats,
)),
),
),
],
),
],
),
),
),
),
),
);
controller.dispose();
if (result == null) return;
try {
await BudgetApi.upsert(
_month.year,
_month.month,
categoryId: categoryId,
amount: result.amount,
recurring: result.recurring,
);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _openAiRecommendations() async {
if (_aiLoading) return;
setState(() => _aiLoading = true);
try {
var recommendation = await BudgetApi.recommendations(
_month.year,
_month.month,
);
if (!mounted) return;
if (recommendation.items.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(recommendation.message)));
return;
}
final totalController = TextEditingController(
text: recommendation.suggestedTotal.toStringAsFixed(0),
);
final controllers = <int, TextEditingController>{
for (final item in recommendation.items)
item.categoryId: TextEditingController(
text: item.suggestedAmount.toStringAsFixed(0),
),
};
var recurring = true;
var refining = false;
final instructionController = TextEditingController();
final history = <BudgetRefinementTurn>[];
Future<void> refineDraft(
BuildContext sheetContext,
StateSetter setSheetState,
) async {
final instruction = instructionController.text.trim();
if (instruction.isEmpty || refining) return;
setSheetState(() => refining = true);
try {
final updated = await BudgetApi.refineRecommendation(
_month.year,
_month.month,
instruction: instruction,
suggestedTotal:
double.tryParse(totalController.text.trim()) ??
recommendation.suggestedTotal,
amounts: {
for (final item in recommendation.items)
item.categoryId:
double.tryParse(
controllers[item.categoryId]?.text.trim() ?? '',
) ??
item.suggestedAmount,
},
history: history,
);
if (!sheetContext.mounted) return;
for (final item in updated.items) {
final controller = controllers.putIfAbsent(
item.categoryId,
() => TextEditingController(),
);
controller.text = item.suggestedAmount.toStringAsFixed(0);
}
totalController.text = updated.suggestedTotal.toStringAsFixed(0);
final assistantText = updated.adjustmentSummary.isNotEmpty
? updated.adjustmentSummary
: '预算草稿已按要求调整';
setSheetState(() {
recommendation = updated;
history.add(
BudgetRefinementTurn(role: 'user', content: instruction),
);
history.add(
BudgetRefinementTurn(role: 'assistant', content: assistantText),
);
if (history.length > 12) {
history.removeRange(0, history.length - 12);
}
instructionController.clear();
});
} catch (error) {
if (sheetContext.mounted) {
ScaffoldMessenger.of(
sheetContext,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
} finally {
if (sheetContext.mounted) {
setSheetState(() => refining = false);
}
}
}
final result =
await showModalBottomSheet<
({bool recurring, Map<int?, double> amounts})
>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => StatefulBuilder(
builder: (context, setSheetState) => FractionallySizedBox(
heightFactor: 0.9,
child: Material(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Column(
children: [
SizedBox(height: 10),
Container(
width: 38,
height: 4,
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 10),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: context.jz.aiBackground,
shape: BoxShape.circle,
),
child: Center(
child: AppIcons.icon(
AppIcons.sparkle,
size: 17,
color: AppTheme.ai,
),
),
),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI 预算建议',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
Text(
'确认前可以修改每一项金额',
style: TextStyle(
fontSize: 11.5,
color: context.jz.text3,
),
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recommendation.adjustmentSummary.isNotEmpty
? recommendation.adjustmentSummary
: recommendation.message,
style: TextStyle(
fontSize: 12,
height: 1.45,
color: context.jz.text2,
),
),
...recommendation.warnings.map(
(warning) => Padding(
padding: const EdgeInsets.only(top: 5),
child: Text(
$warning',
style: TextStyle(
fontSize: 11,
color: AppTheme.orange,
),
),
),
),
],
),
),
),
SizedBox(height: 8),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
children: [
...history.map(
(turn) => Align(
alignment: turn.role == 'user'
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
constraints: const BoxConstraints(
maxWidth: 280,
),
margin: const EdgeInsets.only(bottom: 7),
padding: const EdgeInsets.symmetric(
horizontal: 11,
vertical: 8,
),
decoration: BoxDecoration(
color: turn.role == 'user'
? AppTheme.ai
: Colors.white,
borderRadius: BorderRadius.circular(12),
border: turn.role == 'user'
? null
: Border.all(color: context.jz.line),
),
child: Text(
turn.content,
style: TextStyle(
fontSize: 11.5,
color: turn.role == 'user'
? Colors.white
: context.jz.text2,
),
),
),
),
),
_recommendationRow(
iconKey: 'wallet',
name: '本月总预算',
detail: '分类建议合计',
controller: totalController,
emphasized: true,
),
...recommendation.items.map(
(item) => _recommendationRow(
iconKey: item.categoryIcon,
colorKey: item.colorKey,
name: item.categoryName,
detail:
'近月均值 ¥${item.historicalAverage.toStringAsFixed(0)} · 已花 ¥${item.currentSpent.toStringAsFixed(0)} · ${_confidenceText(item.confidence)}',
controller: controllers[item.categoryId]!,
),
),
JzSwitchTile(
value: recurring,
title: '设为周期预算',
subtitle: '每月自动沿用这套预算',
onChanged: (value) =>
setSheetState(() => recurring = value),
),
SizedBox(height: 4),
Container(
padding: const EdgeInsets.fromLTRB(12, 6, 6, 6),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: instructionController,
enabled: !refining,
minLines: 1,
maxLines: 3,
maxLength: 500,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
counterText: '',
hintText: '继续调整,如:餐饮减少 200',
contentPadding: EdgeInsets.symmetric(
vertical: 8,
),
),
),
),
SizedBox(width: 8),
Material(
color: refining
? context.jz.line
: AppTheme.ai,
borderRadius: BorderRadius.circular(11),
child: InkWell(
onTap: refining
? null
: () => refineDraft(
sheetContext,
setSheetState,
),
borderRadius: BorderRadius.circular(11),
child: SizedBox(
width: 46,
height: 40,
child: Center(
child: refining
? SizedBox(
width: 17,
height: 17,
child:
CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: AppIcons.icon(
AppIcons.sparkle,
size: 17,
color: Colors.white,
),
),
),
),
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
child: SizedBox(
width: double.infinity,
height: 46,
child: FilledButton(
onPressed: refining
? null
: () {
final amounts = <int?, double>{
null:
double.tryParse(
totalController.text,
) ??
0,
for (final item in recommendation.items)
item.categoryId:
double.tryParse(
controllers[item.categoryId]!
.text,
) ??
0,
};
Navigator.pop(sheetContext, (
recurring: recurring,
amounts: amounts,
));
},
child: Text('应用这套预算'),
),
),
),
],
),
),
),
),
),
);
totalController.dispose();
for (final controller in controllers.values) {
controller.dispose();
}
instructionController.dispose();
if (result == null) return;
await BudgetApi.applyBatch(
_month.year,
_month.month,
recurring: result.recurring,
amounts: result.amounts,
);
await _load();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('预算方案已应用')));
}
} catch (e) {
if (mounted) _showError(e);
} finally {
if (mounted) setState(() => _aiLoading = false);
}
}
Widget _recommendationRow({
required String iconKey,
String? colorKey,
required String name,
required String detail,
required TextEditingController controller,
bool emphasized = false,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: emphasized ? context.jz.aiBackground : context.jz.card,
borderRadius: BorderRadius.circular(13),
border: Border.all(color: context.jz.line),
),
child: Row(
children: [
CategoryIconBox(iconKey: iconKey, colorKey: colorKey, size: 36),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w700),
),
SizedBox(height: 2),
Text(
detail,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
],
),
),
SizedBox(width: 8),
SizedBox(
width: 86,
child: TextField(
controller: controller,
textAlign: TextAlign.end,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
prefixText: '¥',
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 9,
),
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final data = _data;
final total = data?.total;
return Scaffold(
appBar: AppBar(title: Text('预算管理')),
body: _loading && data == null
? Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_monthNavigator(),
SizedBox(height: 12),
_totalCard(total),
SizedBox(height: 10),
_budgetActions(data),
SizedBox(height: 10),
_categoryCard(data),
],
),
),
);
}
Widget _monthNavigator() {
final now = ShanghaiTime.now;
final canGoNext =
_month.year < now.year ||
(_month.year == now.year && _month.month < now.month);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () => _changeMonth(-1),
icon: Icon(Icons.chevron_left_rounded),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
),
child: Text(
'${_month.year}${_month.month} 月',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
),
IconButton(
onPressed: canGoNext ? () => _changeMonth(1) : null,
icon: Icon(Icons.chevron_right_rounded),
),
],
);
}
Widget _totalCard(BudgetItem? total) {
return Card(
child: InkWell(
onTap: () => _edit(
current: total?.amount,
recurring: total?.isRecurring ?? false,
),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: total == null
? Padding(
padding: EdgeInsets.symmetric(vertical: 18),
child: Center(
child: Text(
'点击设置总预算',
style: TextStyle(fontSize: 13, color: context.jz.text2),
),
),
)
: Row(
children: [
SizedBox(
width: 92,
height: 92,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 92,
height: 92,
child: CircularProgressIndicator(
value: total.ratio,
strokeWidth: 9,
backgroundColor: context.jz.line,
color: total.ratio > 0.85
? AppTheme.red
: AppTheme.primary,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'已用',
style: TextStyle(
fontSize: 9.5,
color: context.jz.text3,
),
),
Text(
'${(total.ratio * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
],
),
],
),
),
SizedBox(width: 18),
Expanded(
child: Column(
children: [
_keyValue(
total.isRecurring ? '周期总预算' : '本月总预算',
${total.amount.toStringAsFixed(2)}',
),
_keyValue(
'已使用',
${total.spent.toStringAsFixed(2)}',
),
_keyValue(
'剩余可用',
${(total.amount - total.spent).toStringAsFixed(2)}',
color: AppTheme.primary,
),
],
),
),
],
),
),
),
);
}
Widget _categoryCard(BudgetsData? data) {
final budgets = data?.categories ?? const <BudgetItem>[];
return Card(
child: Column(
children: [
...budgets.map(
(budget) => InkWell(
onTap: () => _edit(
categoryId: budget.categoryId,
categoryName: budget.categoryName,
current: budget.amount,
recurring: budget.isRecurring,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 11,
),
child: Row(
children: [
CategoryIconBox(
iconKey: budget.categoryIcon ?? 'tag',
colorKey: budget.categoryColor,
size: 34,
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
budget.categoryName ?? '',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
),
),
),
if (budget.isRecurring) ...[
SizedBox(width: 5),
_tag('每月'),
],
Spacer(),
Text(
${budget.spent.toStringAsFixed(0)} / ¥${budget.amount.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 11.5,
color: context.jz.text3,
),
),
],
),
SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: budget.ratio,
minHeight: 4,
backgroundColor: context.jz.background,
color: budget.ratio > 0.85
? AppTheme.red
: budget.ratio > 0.7
? AppTheme.orange
: AppTheme.primary,
),
),
],
),
),
],
),
),
),
),
],
),
);
}
Widget _budgetActions(BudgetsData? data) {
final budgets = data?.categories ?? const <BudgetItem>[];
final existing = budgets.map((budget) => budget.categoryId).toSet();
final canAdd = _categories.any(
(category) => !existing.contains(category.id),
);
return Row(
children: [
if (SessionStore.instance.aiEnabled) ...[
Expanded(
child: _BudgetActionCard(
label: _aiLoading ? '正在分析…' : 'AI 订预算',
description: '参考近三个月消费',
icon: AppIcons.sparkle,
foreground: AppTheme.ai,
background: context.jz.aiBackground,
loading: _aiLoading,
onTap: _aiLoading ? null : _openAiRecommendations,
),
),
SizedBox(width: 10),
],
Expanded(
child: _BudgetActionCard(
label: '添加分类预算',
description: canAdd ? '为单项消费设上限' : '分类预算已全部设置',
icon: AppIcons.plus,
foreground: AppTheme.primaryDeep,
background: context.jz.primaryBackground,
onTap: canAdd ? () => _addCategoryBudget(budgets) : null,
),
),
],
);
}
Future<void> _addCategoryBudget(List<BudgetItem> budgets) async {
final existing = budgets.map((budget) => budget.categoryId).toSet();
final candidates = _categories
.where((category) => !existing.contains(category.id))
.toList();
if (candidates.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('所有支出分类都已设置预算')));
return;
}
final category = await showJzOptionSheet<CategoryItem>(
context,
title: '添加分类预算',
subtitle: '选择一个尚未设置预算的支出分类',
options: candidates
.map(
(item) => JzOption(
value: item,
label: item.name,
leading: CategoryIconBox(
iconKey: item.iconKey,
colorKey: item.colorKey,
size: 34,
),
),
)
.toList(),
);
if (category != null) {
await _edit(categoryId: category.id, categoryName: category.name);
}
}
Widget _tag(String text) => Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: context.jz.aiBackground,
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: TextStyle(
fontSize: 9,
color: AppTheme.ai,
fontWeight: FontWeight.w700,
),
),
);
String _confidenceText(String confidence) => switch (confidence) {
'high' => '依据充分',
'medium' => '依据一般',
_ => '初步估算',
};
Widget _keyValue(String key, String value, {Color? color}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Text(key, style: TextStyle(fontSize: 12, color: context.jz.text2)),
Spacer(),
Text(
value,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: color ?? context.jz.text,
),
),
],
),
);
void _showError(Object error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
}
class _BudgetActionCard extends StatelessWidget {
final String label;
final String description;
final String icon;
final Color foreground;
final Color background;
final bool loading;
final VoidCallback? onTap;
const _BudgetActionCard({
required this.label,
required this.description,
required this.icon,
required this.foreground,
required this.background,
this.loading = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return Semantics(
button: true,
enabled: enabled,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: enabled ? 1 : 0.48,
child: Container(
height: 68,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: foreground.withValues(alpha: 0.16)),
),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: loading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: foreground,
),
)
: AppIcons.icon(icon, size: 17, color: foreground),
),
),
SizedBox(width: 9),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
color: foreground,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 3),
Text(
description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9.5,
color: context.jz.text3,
),
),
],
),
),
],
),
),
),
),
);
}
}