announcement_create_page.dart 26 KB

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