Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 167 additions & 43 deletions jive-flutter/lib/screens/management/category_management_enhanced.dart
Original file line number Diff line number Diff line change
@@ -1,60 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/category.dart';
import '../../models/category_template.dart';
import '../../providers/category_provider.dart';
import '../../providers/ledger_provider.dart';
import '../../services/api/category_service.dart';
import '../../services/api/category_service_integrated.dart';
import '../../widgets/bottom_sheets/import_details_sheet.dart';

// 占位版:增强分类管理页面暂时下线以稳定测试。
// 后续 PR 将恢复原完整交互(模板导入 / 拖拽排序 / 批量操作 / 转标签 / 统计等)。
class CategoryManagementEnhancedPage extends StatelessWidget {
class CategoryManagementEnhancedPage extends ConsumerStatefulWidget {
const CategoryManagementEnhancedPage({super.key});

@override
ConsumerState<CategoryManagementEnhancedPage> createState() => _CategoryManagementEnhancedPageState();
}

class _CategoryManagementEnhancedPageState extends ConsumerState<CategoryManagementEnhancedPage> {
bool _busy = false;

@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('分类管理 (占位)')),
appBar: AppBar(
title: const Text('分类管理'),
actions: [
IconButton(
tooltip: '从模板库导入',
icon: const Icon(Icons.library_add),
onPressed: _busy ? null : _showTemplateLibrary,
),
],
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.category_outlined, size: 72, color: cs.primary),
const SizedBox(height: 16),
const Text(
'增强版分类管理暂时下线',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Text(
'为稳定当前 PR 的测试环境,复杂分类增强功能已暂时移除。',
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurface.withOpacity(.72)),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('提示'),
content: const Text('完整功能将于后续 PR 恢复'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
)
child: _busy
? const CircularProgressIndicator()
: const Text('分类管理(最小版):点击右上角导入模板')
),
);
}

Future<void> _showTemplateLibrary() async {
final ledgerId = ref.read(currentLedgerProvider)?.id;
if (ledgerId == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('无当前账本,无法导入模板')));
return;
}

setState(() { _busy = true; });
List<SystemCategoryTemplate> templates = [];
try {
templates = await CategoryServiceIntegrated().getAllTemplates(forceRefresh: true);
} catch (_) {}
Comment on lines +52 to +54

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This try-catch block currently swallows errors silently. If fetching templates fails, the user will be presented with an empty list in the dialog without any explanation, which can be confusing. It's important to provide feedback to the user when an error occurs, for instance, by showing a SnackBar.

Suggested change
try {
templates = await CategoryServiceIntegrated().getAllTemplates(forceRefresh: true);
} catch (_) {}
try {
templates = await CategoryServiceIntegrated().getAllTemplates(forceRefresh: true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('获取模板失败: $e')));
}
}

if (!mounted) return;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using if (!mounted) after an await is a common way to prevent errors from calling setState on a disposed widget, but it's often considered an anti-pattern in modern Flutter development. It can indicate that state updates are not properly tied to the widget's lifecycle.

For more robust asynchronous operations, consider using a FutureProvider or StateNotifier from Riverpod to manage the loading state and data fetching. This would handle the widget lifecycle automatically and remove the need for manual mounted checks and setState calls for the _busy flag.

setState(() { _busy = false; });

final selected = <SystemCategoryTemplate>{};
String conflict = 'skip'; // skip|rename|update
ImportResult? preview;

await showDialog<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: const Text('从模板库导入'),
content: SizedBox(
width: 480,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text('冲突策略: '),
const SizedBox(width: 8),
DropdownButton<String>(
value: conflict,
items: const [
DropdownMenuItem(value: 'skip', child: Text('跳过')),
DropdownMenuItem(value: 'rename', child: Text('重命名')),
DropdownMenuItem(value: 'update', child: Text('覆盖')),
],
onChanged: (v) { if (v!=null) setLocal((){ conflict = v; }); },
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space after 'if' and around '!=' operator. Should be formatted as 'if (v != null)' for consistency with Dart style guidelines.

Suggested change
onChanged: (v) { if (v!=null) setLocal((){ conflict = v; }); },
onChanged: (v) { if (v != null) setLocal((){ conflict = v; }); },

Copilot uses AI. Check for mistakes.
),
],
),
),
child: const Text('占位'),
const SizedBox(height: 8),
SizedBox(
height: 320,
child: ListView.builder(
itemCount: templates.length,
itemBuilder: (_, i) {
final t = templates[i];
final checked = selected.contains(t);
return CheckboxListTile(
value: checked,
onChanged: (_) => setLocal((){
if (checked) { selected.remove(t); } else { selected.add(t); }
Comment on lines +98 to +99
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space after 'setLocal' before the opening parenthesis. Should be formatted as 'setLocal(() {' for consistency with Dart formatting standards.

Copilot uses AI. Check for mistakes.
}),
dense: true,
title: Text(t.name),
subtitle: Text(t.classification.name),
);
},
),
),
if (preview != null) ...[
const Divider(),
Align(
alignment: Alignment.centerLeft,
child: Text('预览(服务端 dry-run )', style: Theme.of(context).textTheme.titleSmall),
),
SizedBox(
height: 160,
child: ListView.builder(
itemCount: preview!.details.length,
itemBuilder: (_, i) {
final d = preview!.details[i];
final color = (d.action == 'failed' || d.action == 'skipped') ? Colors.orange : Colors.green;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoded colors like Colors.orange and Colors.green are used here to indicate status. It's a better practice to use colors from the app's theme (e.g., Theme.of(context).colorScheme) to ensure UI consistency and support for different themes (like light and dark mode). For example, you could use Theme.of(context).colorScheme.primary for success and Theme.of(context).colorScheme.error for failures.

return ListTile(
dense: true,
title: Text(d.finalName ?? d.originalName),
subtitle: Text(d.action + (d.reason!=null ? ' (${d.reason})' : '')),
trailing: Icon(
d.action == 'failed' ? Icons.error : (d.action=='skipped'? Icons.warning_amber : Icons.check_circle),
color: color,
),
);
},
),
),
],
],
),
const SizedBox(height: 12),
const Text(
'TODO: 模板导入 / 拖拽排序 / 批量操作 / 统计 重新引入',
style: TextStyle(fontSize: 11, color: Colors.grey),
textAlign: TextAlign.center,
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
TextButton(
onPressed: selected.isEmpty ? null : () async {
try {
final items = selected.map((t) => { 'template_id': t.id }).toList();
final res = await CategoryService().importTemplatesAdvanced(
ledgerId: ledgerId,
items: items,
onConflict: conflict,
dryRun: true,
);
setLocal((){ preview = res; });
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space after opening parenthesis. Should be formatted as 'setLocal(() { preview = res; });' for consistency with Dart formatting standards.

Suggested change
setLocal((){ preview = res; });
setLocal(() { preview = res; });

Copilot uses AI. Check for mistakes.
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('预览失败: $e')));
}
}
},
child: const Text('预览'),
),
FilledButton(
onPressed: (selected.isEmpty) ? null : () async {
Navigator.pop(ctx);
try {
final items = selected.map((t) => { 'template_id': t.id }).toList();
final result = await CategoryService().importTemplatesAdvanced(
ledgerId: ledgerId,
items: items,
onConflict: conflict,
);
if (!mounted) return;
await ref.read(userCategoriesProvider.notifier).refreshFromBackend(ledgerId: ledgerId);
await ImportDetailsSheet.show(context, result);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('导入失败: $e')));
}
},
Comment on lines +159 to +175

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When the '确认导入' (Confirm Import) button is pressed, the dialog is closed, and the import process begins. However, the main screen doesn't show a loading indicator because the _busy state is not updated. This can make the UI feel unresponsive during the import. It's better to provide visual feedback to the user that an operation is in progress.

                onPressed: (selected.isEmpty) ? null : () async {
                  Navigator.pop(ctx);
                  setState(() { _busy = true; });
                  try {
                    final items = selected.map((t) => { 'template_id': t.id }).toList();
                    final result = await CategoryService().importTemplatesAdvanced(
                      ledgerId: ledgerId,
                      items: items,
                      onConflict: conflict,
                    );
                    if (!mounted) return;
                    await ref.read(userCategoriesProvider.notifier).refreshFromBackend(ledgerId: ledgerId);
                    await ImportDetailsSheet.show(context, result);
                  } catch (e) {
                    if (!mounted) return;
                    ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('导入失败: $e')));
                  } finally {
                    if (mounted) {
                      setState(() { _busy = false; });
                    }
                  }
                },

child: const Text('确认导入'),
),
],
Comment on lines +137 to 178

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to create the items list from the selected templates is duplicated in the onPressed handlers for both the '预览' (Preview) and '确认导入' (Confirm Import) buttons (lines 142 and 162).

To improve maintainability and avoid redundancy, you can extract this logic. A good approach would be to define final items = selected.map((t) => { 'template_id': t.id }).toList(); at the beginning of the StatefulBuilder's builder method and then reuse the items variable in both handlers.

),
),
),
);
},
);
}
}
Loading