announcement_create_page.dart 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../core/utils/responsive.dart';
  6. import '../../shared/widgets/action_bar.dart';
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../core/i18n/app_localizations.dart';
  9. import '../../core/theme/app_colors_extension.dart';
  10. class AnnouncementCreatePage extends ConsumerStatefulWidget {
  11. const AnnouncementCreatePage({super.key});
  12. @override
  13. ConsumerState<AnnouncementCreatePage> createState() =>
  14. _AnnouncementCreatePageState();
  15. }
  16. class _AnnouncementCreatePageState
  17. extends ConsumerState<AnnouncementCreatePage> {
  18. final _titleCtrl = TextEditingController();
  19. final _contentCtrl = TextEditingController();
  20. String _type = '通知公告';
  21. bool _isTop = false;
  22. DateTime? _expiryDate;
  23. // 接收范围
  24. int _scopeMode = 0; // 0=全员, 1=按部门, 2=按指定用户
  25. final List<String> _selectedDepts = [];
  26. final List<String> _selectedUsers = [];
  27. final int _totalCoverage = 128; // 模拟覆盖人数
  28. // 附件模拟
  29. final List<String> _attachments = [];
  30. static const int _maxAttachments = 5;
  31. final _types = ['通知公告', '人事与制度', '放假与活动'];
  32. final List<String> _mockDepts = [
  33. '市场部',
  34. '技术部',
  35. '销售部',
  36. '财务部',
  37. '人力资源部',
  38. '行政管理部',
  39. ];
  40. @override
  41. void dispose() {
  42. _titleCtrl.dispose();
  43. _contentCtrl.dispose();
  44. super.dispose();
  45. }
  46. Future<void> _pickType() async {
  47. final l10n = AppLocalizations.of(context);
  48. final result = await showDialog<String>(
  49. context: context,
  50. builder: (ctx) => TDAlertDialog.vertical(
  51. title: l10n.get('announcementTypes'),
  52. buttons: _types
  53. .map(
  54. (t) => TDDialogButtonOptions(
  55. title: t,
  56. action: () => Navigator.pop(ctx, t),
  57. ),
  58. )
  59. .toList(),
  60. ),
  61. );
  62. if (result != null) setState(() => _type = result);
  63. }
  64. void _pickExpiryDate() {
  65. final l10n = AppLocalizations.of(context);
  66. final initial = _expiryDate ?? DateTime.now().add(const Duration(days: 30));
  67. TDPicker.showDatePicker(
  68. context,
  69. title: l10n.get('selectExpiryDate'),
  70. useYear: true,
  71. useMonth: true,
  72. useDay: true,
  73. useHour: false,
  74. useMinute: false,
  75. initialDate: [initial.year, initial.month, initial.day],
  76. onConfirm: (selected) {
  77. setState(() {
  78. _expiryDate = DateTime(
  79. selected['year'] ?? initial.year,
  80. selected['month'] ?? initial.month,
  81. selected['day'] ?? initial.day,
  82. );
  83. });
  84. },
  85. );
  86. }
  87. void _showScopeDrawer() {
  88. showModalBottomSheet(
  89. context: context,
  90. isScrollControlled: true,
  91. shape: const RoundedRectangleBorder(
  92. borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
  93. ),
  94. builder: (ctx) => _buildScopeDrawerContent(ctx),
  95. );
  96. }
  97. Widget _buildScopeDrawerContent(BuildContext ctx) {
  98. final l10n = AppLocalizations.of(ctx);
  99. return StatefulBuilder(
  100. builder: (context, setInnerState) {
  101. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  102. return Container(
  103. height: MediaQuery.of(context).size.height * 0.7,
  104. padding: const EdgeInsets.all(16),
  105. child: Column(
  106. crossAxisAlignment: CrossAxisAlignment.start,
  107. children: [
  108. Row(
  109. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  110. children: [
  111. Text(
  112. l10n.get('recipientScope'),
  113. style: TextStyle(
  114. fontSize: 16,
  115. fontWeight: FontWeight.w600,
  116. color: colors.textPrimary,
  117. ),
  118. ),
  119. GestureDetector(
  120. onTap: () => Navigator.pop(context),
  121. child: const Icon(Icons.close, size: 20),
  122. ),
  123. ],
  124. ),
  125. const SizedBox(height: 16),
  126. RadioGroup<int>(
  127. groupValue: _scopeMode,
  128. onChanged: (v) {
  129. setInnerState(() => _scopeMode = v!);
  130. setState(() {});
  131. },
  132. child: Column(
  133. crossAxisAlignment: CrossAxisAlignment.start,
  134. children: [
  135. _buildScopeOption(
  136. context,
  137. l10n.get('allStaff'),
  138. l10n.get('scopeAllStaff'),
  139. 0,
  140. setInnerState,
  141. ),
  142. const SizedBox(height: 8),
  143. _buildScopeOption(
  144. context,
  145. l10n.get('byDept'),
  146. l10n.get('byDeptHint'),
  147. 1,
  148. setInnerState,
  149. ),
  150. const SizedBox(height: 8),
  151. _buildScopeOption(
  152. context,
  153. l10n.get('byUser'),
  154. l10n.get('byUserHint'),
  155. 2,
  156. setInnerState,
  157. ),
  158. ],
  159. ),
  160. ),
  161. if (_scopeMode == 1) ...[
  162. const SizedBox(height: 12),
  163. Text(
  164. l10n.get('selectDept'),
  165. style: TextStyle(fontSize: 13, color: colors.textSecondary),
  166. ),
  167. const SizedBox(height: 4),
  168. ..._mockDepts.map(
  169. (dept) => CheckboxListTile(
  170. title: Text(dept, style: const TextStyle(fontSize: 14)),
  171. value: _selectedDepts.contains(dept),
  172. onChanged: (checked) {
  173. setInnerState(() {
  174. if (checked == true) {
  175. _selectedDepts.add(dept);
  176. } else {
  177. _selectedDepts.remove(dept);
  178. }
  179. });
  180. setState(() {});
  181. },
  182. dense: true,
  183. contentPadding: EdgeInsets.zero,
  184. ),
  185. ),
  186. ],
  187. if (_scopeMode == 2) ...[
  188. const SizedBox(height: 12),
  189. Text(
  190. l10n.get('searchEmployee'),
  191. style: TextStyle(fontSize: 13, color: colors.textSecondary),
  192. ),
  193. const SizedBox(height: 4),
  194. Container(
  195. padding: const EdgeInsets.symmetric(
  196. horizontal: 10,
  197. vertical: 2,
  198. ),
  199. decoration: BoxDecoration(
  200. color: colors.bgPage,
  201. borderRadius: BorderRadius.circular(4),
  202. ),
  203. child: TDInput(hintText: l10n.get('searchEmployeeHint')),
  204. ),
  205. const SizedBox(height: 8),
  206. Text(
  207. l10n.getString('selectedCount', args: {'count': '${_selectedUsers.length}'}),
  208. style: TextStyle(fontSize: 12, color: colors.textSecondary),
  209. ),
  210. ],
  211. const Spacer(),
  212. // 覆盖统计
  213. Container(
  214. padding: const EdgeInsets.all(12),
  215. decoration: BoxDecoration(
  216. color: colors.primaryLight,
  217. borderRadius: BorderRadius.circular(8),
  218. ),
  219. child: Row(
  220. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  221. children: [
  222. Text(
  223. l10n.get('coverageCount'),
  224. style: TextStyle(fontSize: 14, color: colors.textPrimary),
  225. ),
  226. Text(
  227. '${_scopeMode == 0 ? _totalCoverage : (_scopeMode == 1 ? _selectedDepts.length * 15 : _selectedUsers.length)} ${l10n.get('personUnit')}',
  228. style: TextStyle(
  229. fontSize: 16,
  230. fontWeight: FontWeight.w700,
  231. color: colors.primary,
  232. ),
  233. ),
  234. ],
  235. ),
  236. ),
  237. const SizedBox(height: 12),
  238. SizedBox(
  239. width: double.infinity,
  240. child: TDButton(
  241. text: l10n.get('confirm'),
  242. size: TDButtonSize.large,
  243. theme: TDButtonTheme.primary,
  244. isBlock: true,
  245. onTap: () => Navigator.pop(context),
  246. ),
  247. ),
  248. ],
  249. ),
  250. );
  251. },
  252. );
  253. }
  254. Widget _buildScopeOption(
  255. BuildContext context,
  256. String title,
  257. String subtitle,
  258. int mode,
  259. void Function(void Function()) setInnerState,
  260. ) {
  261. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  262. final selected = _scopeMode == mode;
  263. return GestureDetector(
  264. onTap: () {
  265. setInnerState(() => _scopeMode = mode);
  266. setState(() {});
  267. },
  268. child: Container(
  269. padding: const EdgeInsets.all(12),
  270. decoration: BoxDecoration(
  271. color: selected ? colors.primaryLight : colors.bgPage,
  272. borderRadius: BorderRadius.circular(8),
  273. border: Border.all(
  274. color: selected ? colors.primary : Colors.transparent,
  275. ),
  276. ),
  277. child: Row(
  278. children: [
  279. Radio<int>(value: mode, activeColor: colors.primary),
  280. const SizedBox(width: 8),
  281. Column(
  282. crossAxisAlignment: CrossAxisAlignment.start,
  283. children: [
  284. Text(
  285. title,
  286. style: TextStyle(fontSize: 14, color: colors.textPrimary),
  287. ),
  288. Text(
  289. subtitle,
  290. style: TextStyle(fontSize: 12, color: colors.textSecondary),
  291. ),
  292. ],
  293. ),
  294. ],
  295. ),
  296. ),
  297. );
  298. }
  299. void _confirmPublish() {
  300. final l10n = AppLocalizations.of(context);
  301. showDialog(
  302. context: context,
  303. builder: (ctx) => TDAlertDialog(
  304. title: l10n.get('confirmPublishTitle'),
  305. contentWidget: Text(
  306. l10n.getString(
  307. 'confirmPublishContent',
  308. args: {'title': _titleCtrl.text},
  309. ),
  310. ),
  311. leftBtn: TDDialogButtonOptions(
  312. title: l10n.get('cancel'),
  313. action: () => Navigator.pop(ctx),
  314. ),
  315. rightBtn: TDDialogButtonOptions(
  316. title: l10n.get('confirmPublish'),
  317. action: () {
  318. Navigator.pop(ctx);
  319. TDToast.showText(
  320. l10n.get('announcementPublished'),
  321. context: context,
  322. );
  323. context.pop();
  324. },
  325. ),
  326. ),
  327. );
  328. }
  329. void _saveDraft() {
  330. final l10n = AppLocalizations.of(context);
  331. TDToast.showText(l10n.get('draftSavedToast'), context: context);
  332. }
  333. void _pickAttachment() {
  334. final l10n = AppLocalizations.of(context);
  335. TDToast.showText(l10n.get('attachmentPicker'), context: context);
  336. }
  337. @override
  338. Widget build(BuildContext context) {
  339. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  340. final r = ResponsiveHelper.of(context);
  341. final l10n = AppLocalizations.of(context);
  342. return Column(
  343. children: [
  344. Expanded(
  345. child: Align(
  346. alignment: Alignment.topCenter,
  347. child: ConstrainedBox(
  348. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  349. child: SingleChildScrollView(
  350. padding: const EdgeInsets.symmetric(vertical: 8),
  351. child: Column(
  352. children: [
  353. // 基本信息
  354. FormSection(
  355. title: l10n.get('basicInfo'),
  356. children: [
  357. Container(
  358. padding: const EdgeInsets.symmetric(
  359. horizontal: 10,
  360. vertical: 4,
  361. ),
  362. decoration: BoxDecoration(
  363. color: colors.bgPage,
  364. borderRadius: BorderRadius.circular(4),
  365. ),
  366. child: TDInput(
  367. controller: _titleCtrl,
  368. hintText: l10n.get('enterTitle'),
  369. ),
  370. ),
  371. const SizedBox(height: 12),
  372. GestureDetector(
  373. onTap: _pickType,
  374. child: Container(
  375. padding: const EdgeInsets.symmetric(
  376. horizontal: 10,
  377. vertical: 12,
  378. ),
  379. decoration: BoxDecoration(
  380. color: colors.bgPage,
  381. borderRadius: BorderRadius.circular(4),
  382. ),
  383. child: Row(
  384. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  385. children: [
  386. Text(
  387. _type,
  388. style: TextStyle(
  389. fontSize: 14,
  390. color: colors.textPrimary,
  391. ),
  392. ),
  393. Icon(
  394. Icons.chevron_right,
  395. size: 14,
  396. color: colors.textPlaceholder,
  397. ),
  398. ],
  399. ),
  400. ),
  401. ),
  402. ],
  403. ),
  404. const SizedBox(height: 8),
  405. // 公告正文
  406. FormSection(
  407. title: l10n.get('announcementContent'),
  408. children: [
  409. Container(
  410. padding: const EdgeInsets.all(12),
  411. decoration: BoxDecoration(
  412. color: colors.bgPage,
  413. borderRadius: BorderRadius.circular(4),
  414. ),
  415. height: 200,
  416. child: TDInput(
  417. controller: _contentCtrl,
  418. maxLines: null,
  419. hintText: l10n.get('enterContent'),
  420. ),
  421. ),
  422. ],
  423. ),
  424. const SizedBox(height: 8),
  425. // 附件
  426. FormSection(
  427. title: l10n.get('attachments'),
  428. actionText: _attachments.length >= _maxAttachments
  429. ? l10n.get('limitReached')
  430. : l10n.get('add'),
  431. showAction: _attachments.length < _maxAttachments,
  432. actionIcon: Icons.attach_file,
  433. onActionTap: _pickAttachment,
  434. children: [
  435. if (_attachments.isEmpty)
  436. Text(
  437. l10n.get('attachmentLimit'),
  438. style: TextStyle(
  439. fontSize: 12,
  440. color: colors.textPlaceholder,
  441. ),
  442. )
  443. else
  444. Wrap(
  445. spacing: 8,
  446. runSpacing: 8,
  447. children: _attachments.asMap().entries.map((entry) {
  448. return TDTag(
  449. entry.value,
  450. size: TDTagSize.medium,
  451. needCloseIcon: true,
  452. onCloseTap: () {
  453. setState(
  454. () => _attachments.removeAt(entry.key),
  455. );
  456. },
  457. );
  458. }).toList(),
  459. ),
  460. ],
  461. ),
  462. const SizedBox(height: 8),
  463. // 发布设置
  464. FormSection(
  465. title: l10n.get('publishSettings'),
  466. children: [
  467. _buildSwitchRow(l10n.get('pinAnnouncement'), _isTop, (
  468. v,
  469. ) {
  470. setState(() => _isTop = v);
  471. }),
  472. TDDivider(height: 1, color: colors.border),
  473. GestureDetector(
  474. onTap: _pickExpiryDate,
  475. child: Container(
  476. height: 44,
  477. alignment: Alignment.centerLeft,
  478. child: Row(
  479. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  480. children: [
  481. Text(
  482. l10n.get('validUntil'),
  483. style: TextStyle(
  484. fontSize: 14,
  485. color: colors.textSecondary,
  486. ),
  487. ),
  488. Text(
  489. _expiryDate != null
  490. ? '${_expiryDate!.year}-${_expiryDate!.month.toString().padLeft(2, '0')}-${_expiryDate!.day.toString().padLeft(2, '0')}'
  491. : l10n.get('expiryNever'),
  492. style: TextStyle(
  493. fontSize: 14,
  494. color: _expiryDate != null
  495. ? colors.primary
  496. : colors.textPlaceholder,
  497. ),
  498. ),
  499. Icon(
  500. Icons.chevron_right,
  501. size: 14,
  502. color: colors.textPlaceholder,
  503. ),
  504. ],
  505. ),
  506. ),
  507. ),
  508. TDDivider(height: 1, color: colors.border),
  509. GestureDetector(
  510. onTap: _showScopeDrawer,
  511. child: Container(
  512. height: 44,
  513. alignment: Alignment.centerLeft,
  514. child: Row(
  515. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  516. children: [
  517. Text(
  518. l10n.get('recipientScope'),
  519. style: TextStyle(
  520. fontSize: 14,
  521. color: colors.textSecondary,
  522. ),
  523. ),
  524. Row(
  525. mainAxisSize: MainAxisSize.min,
  526. children: [
  527. Text(
  528. _scopeMode == 0
  529. ? l10n.get('allStaff')
  530. : _scopeMode == 1
  531. ? '${l10n.get('byDept')}(${_selectedDepts.length})'
  532. : '${l10n.get('byUser')}(${_selectedUsers.length})',
  533. style: TextStyle(
  534. fontSize: 14,
  535. color: colors.primary,
  536. ),
  537. ),
  538. Text(
  539. l10n.get('coverageCount'),
  540. style: TextStyle(
  541. fontSize: 12,
  542. color: colors.textPlaceholder,
  543. ),
  544. ),
  545. const SizedBox(width: 4),
  546. Icon(
  547. Icons.chevron_right,
  548. size: 14,
  549. color: colors.textPlaceholder,
  550. ),
  551. ],
  552. ),
  553. ],
  554. ),
  555. ),
  556. ),
  557. ],
  558. ),
  559. const SizedBox(height: 80),
  560. ],
  561. ),
  562. ),
  563. ),
  564. ),
  565. ),
  566. ActionBar(
  567. centerLabel: l10n.get('saveDraft'),
  568. rightLabel: l10n.get('publish'),
  569. showLeft: false,
  570. onCenterTap: _saveDraft,
  571. onRightTap: _confirmPublish,
  572. ),
  573. ],
  574. );
  575. }
  576. Widget _buildSwitchRow(
  577. String label,
  578. bool value,
  579. ValueChanged<bool> onChanged,
  580. ) {
  581. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  582. return Container(
  583. height: 44,
  584. alignment: Alignment.centerLeft,
  585. child: Row(
  586. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  587. children: [
  588. Text(
  589. label,
  590. style: TextStyle(fontSize: 14, color: colors.textSecondary),
  591. ),
  592. TDSwitch(
  593. isOn: value,
  594. onChanged: (v) {
  595. onChanged(v);
  596. return v;
  597. },
  598. ),
  599. ],
  600. ),
  601. );
  602. }
  603. }