announcement_create_page.dart 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735
  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. setNavBarTitle(context, ref, NavBarConfig(
  427. title: l10n.get('announcementCreate'),
  428. showBack: true,
  429. showRight: true,
  430. rightWidget: GestureDetector(
  431. onTap: _showPreview,
  432. child: Text(
  433. l10n.get('preview'),
  434. style: TextStyle(
  435. fontSize: 14,
  436. color: colors.primary,
  437. fontWeight: FontWeight.w500,
  438. ),
  439. ),
  440. ),
  441. onBack: () => context.pop(),
  442. ));
  443. return Column(
  444. children: [
  445. Expanded(
  446. child: Align(
  447. alignment: Alignment.topCenter,
  448. child: ConstrainedBox(
  449. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  450. child: SingleChildScrollView(
  451. padding: const EdgeInsets.symmetric(vertical: 8),
  452. child: Column(
  453. children: [
  454. // 基本信息
  455. FormSection(
  456. title: l10n.get('basicInfo'),
  457. children: [
  458. Container(
  459. padding: const EdgeInsets.symmetric(
  460. horizontal: 10,
  461. vertical: 4,
  462. ),
  463. decoration: BoxDecoration(
  464. color: colors.bgPage,
  465. borderRadius: BorderRadius.circular(4),
  466. ),
  467. child: TDInput(
  468. controller: _titleCtrl,
  469. hintText: l10n.get('enterTitle'),
  470. ),
  471. ),
  472. const SizedBox(height: 12),
  473. GestureDetector(
  474. onTap: _pickType,
  475. child: Container(
  476. padding: const EdgeInsets.symmetric(
  477. horizontal: 10,
  478. vertical: 12,
  479. ),
  480. decoration: BoxDecoration(
  481. color: colors.bgPage,
  482. borderRadius: BorderRadius.circular(4),
  483. ),
  484. child: Row(
  485. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  486. children: [
  487. Text(
  488. _type,
  489. style: TextStyle(
  490. fontSize: 14,
  491. color: colors.textPrimary,
  492. ),
  493. ),
  494. Icon(
  495. Icons.chevron_right,
  496. size: 14,
  497. color: colors.textPlaceholder,
  498. ),
  499. ],
  500. ),
  501. ),
  502. ),
  503. ],
  504. ),
  505. const SizedBox(height: 8),
  506. // 公告正文
  507. FormSection(
  508. title: l10n.get('announcementContent'),
  509. children: [
  510. Container(
  511. padding: const EdgeInsets.all(12),
  512. decoration: BoxDecoration(
  513. color: colors.bgPage,
  514. borderRadius: BorderRadius.circular(4),
  515. ),
  516. height: 200,
  517. child: TDInput(
  518. controller: _contentCtrl,
  519. maxLines: null,
  520. hintText: l10n.get('enterContent'),
  521. ),
  522. ),
  523. ],
  524. ),
  525. const SizedBox(height: 8),
  526. // 附件
  527. FormSection(
  528. title: l10n.get('attachments'),
  529. actionText: _attachments.length >= _maxAttachments
  530. ? l10n.get('limitReached')
  531. : l10n.get('add'),
  532. showAction: _attachments.length < _maxAttachments,
  533. actionIcon: Icons.attach_file,
  534. onActionTap: _pickAttachment,
  535. children: [
  536. if (_attachments.isEmpty)
  537. Text(
  538. l10n.get('attachmentLimit'),
  539. style: TextStyle(
  540. fontSize: 12,
  541. color: colors.textPlaceholder,
  542. ),
  543. )
  544. else
  545. Wrap(
  546. spacing: 8,
  547. runSpacing: 8,
  548. children: _attachments.asMap().entries.map((entry) {
  549. return TDTag(
  550. entry.value,
  551. size: TDTagSize.medium,
  552. needCloseIcon: true,
  553. onCloseTap: () {
  554. setState(
  555. () => _attachments.removeAt(entry.key),
  556. );
  557. },
  558. );
  559. }).toList(),
  560. ),
  561. ],
  562. ),
  563. const SizedBox(height: 8),
  564. // 发布设置
  565. FormSection(
  566. title: l10n.get('publishSettings'),
  567. children: [
  568. _buildSwitchRow(l10n.get('pinAnnouncement'), _isTop, (
  569. v,
  570. ) {
  571. setState(() => _isTop = v);
  572. }),
  573. TDDivider(height: 1, color: colors.border),
  574. GestureDetector(
  575. onTap: _pickExpiryDate,
  576. child: Container(
  577. height: 44,
  578. alignment: Alignment.centerLeft,
  579. child: Row(
  580. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  581. children: [
  582. Text(
  583. l10n.get('validUntil'),
  584. style: TextStyle(
  585. fontSize: 14,
  586. color: colors.textSecondary,
  587. ),
  588. ),
  589. Text(
  590. _expiryDate != null
  591. ? '${_expiryDate!.year}-${_expiryDate!.month.toString().padLeft(2, '0')}-${_expiryDate!.day.toString().padLeft(2, '0')}'
  592. : l10n.get('expiryNever'),
  593. style: TextStyle(
  594. fontSize: 14,
  595. color: _expiryDate != null
  596. ? colors.primary
  597. : colors.textPlaceholder,
  598. ),
  599. ),
  600. Icon(
  601. Icons.chevron_right,
  602. size: 14,
  603. color: colors.textPlaceholder,
  604. ),
  605. ],
  606. ),
  607. ),
  608. ),
  609. TDDivider(height: 1, color: colors.border),
  610. GestureDetector(
  611. onTap: _showScopeDrawer,
  612. child: Container(
  613. height: 44,
  614. alignment: Alignment.centerLeft,
  615. child: Row(
  616. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  617. children: [
  618. Text(
  619. l10n.get('recipientScope'),
  620. style: TextStyle(
  621. fontSize: 14,
  622. color: colors.textSecondary,
  623. ),
  624. ),
  625. Row(
  626. mainAxisSize: MainAxisSize.min,
  627. children: [
  628. Text(
  629. _scopeMode == 0
  630. ? l10n.get('allStaff')
  631. : _scopeMode == 1
  632. ? '${l10n.get('byDept')}(${_selectedDepts.length})'
  633. : '${l10n.get('byUser')}(${_selectedUsers.length})',
  634. style: TextStyle(
  635. fontSize: 14,
  636. color: colors.primary,
  637. ),
  638. ),
  639. Text(
  640. l10n.get('coverageCount'),
  641. style: TextStyle(
  642. fontSize: 12,
  643. color: colors.textPlaceholder,
  644. ),
  645. ),
  646. const SizedBox(width: 4),
  647. Icon(
  648. Icons.chevron_right,
  649. size: 14,
  650. color: colors.textPlaceholder,
  651. ),
  652. ],
  653. ),
  654. ],
  655. ),
  656. ),
  657. ),
  658. ],
  659. ),
  660. const SizedBox(height: 80),
  661. ],
  662. ),
  663. ),
  664. ),
  665. ),
  666. ),
  667. ActionBar(
  668. centerLabel: l10n.get('saveDraft'),
  669. rightLabel: l10n.get('publish'),
  670. showLeft: false,
  671. onCenterTap: _saveDraft,
  672. onRightTap: _confirmPublish,
  673. ),
  674. ],
  675. );
  676. }
  677. Widget _buildSwitchRow(
  678. String label,
  679. bool value,
  680. ValueChanged<bool> onChanged,
  681. ) {
  682. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  683. return Container(
  684. height: 44,
  685. alignment: Alignment.centerLeft,
  686. child: Row(
  687. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  688. children: [
  689. Text(
  690. label,
  691. style: TextStyle(fontSize: 14, color: colors.textSecondary),
  692. ),
  693. TDSwitch(
  694. isOn: value,
  695. onChanged: (v) {
  696. onChanged(v);
  697. return v;
  698. },
  699. ),
  700. ],
  701. ),
  702. );
  703. }
  704. }