import 'package:flutter/material.dart'; import '../api/oa_client.dart'; import '../theme.dart'; import '../widgets/auth_image.dart'; class WxHeader extends StatelessWidget { const WxHeader({super.key, required this.title, this.leading, this.actions = const []}); final String title; final Widget? leading; final List actions; @override Widget build(BuildContext context) { return ColoredBox( color: kHeader, child: SafeArea( bottom: false, child: SizedBox( height: 48, child: Row( children: [ SizedBox(width: 48, child: leading ?? const SizedBox.shrink()), Expanded( child: Text( title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: kInk), ), ), SizedBox( width: actions.isEmpty ? 48 : null, child: Row(mainAxisAlignment: MainAxisAlignment.end, children: actions), ), ], ), ), ), ); } } class WxSearchBar extends StatelessWidget { const WxSearchBar({super.key, this.hint = '搜索', this.onChanged, this.onTap, this.readonly = false}); final String hint; final ValueChanged? onChanged; final VoidCallback? onTap; final bool readonly; @override Widget build(BuildContext context) { final field = Container( height: 36, decoration: BoxDecoration(color: const Color(0xFFF3F3F3), borderRadius: BorderRadius.circular(6)), padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( children: [ const Icon(Icons.search, size: 18, color: kMute), const SizedBox(width: 6), Expanded( child: readonly ? Text(hint, style: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 15)) : TextField( onChanged: onChanged, decoration: InputDecoration( hintText: hint, isDense: true, filled: true, fillColor: Colors.transparent, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: EdgeInsets.zero, ), ), ), ], ), ); return ColoredBox( color: Colors.white, child: Padding( padding: const EdgeInsets.fromLTRB(12, 4, 12, 10), child: onTap == null ? field : GestureDetector(onTap: onTap, child: field), ), ); } } class SquareAvatar extends StatelessWidget { const SquareAvatar({super.key, required this.label, this.size = 44, this.color, this.icon, this.fileId, this.api}); final String label; final double size; final Color? color; final IconData? icon; final String? fileId; final OaClient? api; @override Widget build(BuildContext context) { final bg = color ?? _colorOf(label); final id = (fileId ?? '').trim(); return Container( width: size, height: size, decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), alignment: Alignment.center, clipBehavior: Clip.antiAlias, child: id.isNotEmpty && api != null ? AuthImage(api: api!, fileId: id, width: size, height: size, fit: BoxFit.cover) : icon != null ? Icon(icon, color: Colors.white, size: size * 0.5) : Text( label.isEmpty ? '?' : String.fromCharCodes(label.runes.take(1)), style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600), ), ); } } Color _colorOf(String s) { const palette = [ Color(0xFF267EF0), Color(0xFF07C160), Color(0xFFFA9D3B), Color(0xFF6267F2), Color(0xFF10AEFF), Color(0xFFE75D5D), Color(0xFF00B578), Color(0xFF8B5CF6), ]; return palette[s.hashCode.abs() % palette.length]; } class UnreadBadge extends StatelessWidget { const UnreadBadge(this.count, {super.key, this.dot = false}); final int count; final bool dot; @override Widget build(BuildContext context) { if (count <= 0 && !dot) return const SizedBox.shrink(); if (dot || count <= 0) { return Container(width: 8, height: 8, decoration: const BoxDecoration(color: kDanger, shape: BoxShape.circle)); } final text = count > 99 ? '99+' : '$count'; return Container( constraints: const BoxConstraints(minWidth: 18, minHeight: 18), padding: const EdgeInsets.symmetric(horizontal: 5), decoration: BoxDecoration(color: kDanger, borderRadius: BorderRadius.circular(9)), alignment: Alignment.center, child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)), ); } } class ConvTile extends StatelessWidget { const ConvTile({ super.key, required this.title, this.preview, this.time, this.unread = 0, this.muted = false, this.avatarLabel, this.avatarColor, this.avatarIcon, this.avatarFileId, this.api, this.onTap, this.selected = false, }); final String title; final String? preview; final String? time; final int unread; final bool muted; final String? avatarLabel; final Color? avatarColor; final IconData? avatarIcon; final String? avatarFileId; final OaClient? api; final VoidCallback? onTap; final bool selected; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Container( color: selected ? const Color(0xFFE8F3FF) : Colors.white, padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SquareAvatar(label: avatarLabel ?? title, color: avatarColor, icon: avatarIcon, fileId: avatarFileId, api: api), const SizedBox(width: 10), Expanded( child: Container( padding: const EdgeInsets.only(bottom: 10), decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: kInk)), ), if (time != null && time!.isNotEmpty) Text(time!, style: const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2))), ], ), const SizedBox(height: 4), Row( children: [ Expanded( child: Text( preview ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, color: kMute), ), ), if (muted) const Padding(padding: EdgeInsets.only(left: 6), child: Icon(Icons.notifications_off_outlined, size: 14, color: kMute)), if (unread > 0) Padding(padding: const EdgeInsets.only(left: 6), child: UnreadBadge(unread)), ], ), ], ), ), ), ], ), ), ); } } class GroupCard extends StatelessWidget { const GroupCard({super.key, required this.title, required this.child, this.trailing}); final String title; final Widget child; final Widget? trailing; @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(14, 12, 12, 4), child: Row( children: [ Expanded(child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kInk))), if (trailing != null) trailing!, ], ), ), child, ], ), ); } } class AppGrid extends StatelessWidget { const AppGrid({super.key, required this.items}); final List items; @override Widget build(BuildContext context) { return GridView.count( crossAxisCount: 4, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(4, 4, 4, 12), childAspectRatio: 0.92, children: [ for (final it in items) InkWell( onTap: it.onTap, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 44, height: 44, decoration: BoxDecoration(color: it.color, borderRadius: BorderRadius.circular(10)), child: Icon(it.icon, color: Colors.white, size: 24), ), const SizedBox(height: 6), Text( it.label, textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kInk), ), ], ), ), ], ); } } class AppGridItem { const AppGridItem({required this.label, required this.icon, required this.color, required this.onTap}); final String label; final IconData icon; final Color color; final VoidCallback onTap; } class CellGroup extends StatelessWidget { const CellGroup({super.key, required this.children}); final List children; @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), child: Column(children: children), ); } } class Cell extends StatelessWidget { const Cell({super.key, required this.title, this.subtitle, this.leading, this.trailing, this.onTap, this.showLine = true}); final String title; final String? subtitle; final Widget? leading; final Widget? trailing; final VoidCallback? onTap; final bool showLine; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.only(left: 16), child: Container( padding: const EdgeInsets.fromLTRB(0, 14, 12, 14), decoration: showLine ? const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))) : null, child: Row( children: [ if (leading != null) ...[leading!, const SizedBox(width: 12)], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: const TextStyle(fontSize: 16, color: kInk)), if (subtitle != null && subtitle!.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2), child: Text(subtitle!, style: const TextStyle(fontSize: 12, color: kMute)), ), ], ), ), if (trailing != null) trailing!, if (onTap != null) const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20), ], ), ), ), ); } } class WxTabBar extends StatelessWidget { const WxTabBar({super.key, required this.index, required this.onChanged, required this.items}); final int index; final ValueChanged onChanged; final List<(IconData, IconData, String, int)> items; @override Widget build(BuildContext context) { return ColoredBox( color: const Color(0xFFF7F7F7), child: SafeArea( top: false, child: SizedBox( height: 54, child: Row( children: [ for (var i = 0; i < items.length; i++) Expanded( child: InkWell( onTap: () => onChanged(i), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Stack( clipBehavior: Clip.none, children: [ Icon(index == i ? items[i].$2 : items[i].$1, size: 24, color: index == i ? kBind : const Color(0xFF646464)), if (items[i].$4 > 0) Positioned(right: -10, top: -4, child: UnreadBadge(items[i].$4)), ], ), const SizedBox(height: 2), Text(items[i].$3, style: TextStyle(fontSize: 10, color: index == i ? kBind : const Color(0xFF646464))), ], ), ), ), ], ), ), ), ); } } Future showWxSheet(BuildContext context, List<(String, T)> actions) { return showModalBottomSheet( context: context, backgroundColor: Colors.transparent, builder: (ctx) => Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Column( children: [ for (var i = 0; i < actions.length; i++) InkWell( onTap: () => Navigator.pop(ctx, actions[i].$2), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 16), decoration: i == 0 ? null : const BoxDecoration(border: Border(top: BorderSide(color: kLine, width: 0.5))), alignment: Alignment.center, child: Text(actions[i].$1, style: const TextStyle(fontSize: 17, color: kInk)), ), ), ], ), ), const SizedBox(height: 8), Material( color: Colors.white, borderRadius: BorderRadius.circular(12), child: InkWell( onTap: () => Navigator.pop(ctx), borderRadius: BorderRadius.circular(12), child: const SizedBox( width: double.infinity, height: 52, child: Center(child: Text('取消', style: TextStyle(fontSize: 17, color: kBind, fontWeight: FontWeight.w500))), ), ), ), ], ), ), ); } /// 微信风格的页内提示:不遮挡输入框和系统导航区域。 void showWxToast(BuildContext context, String message, {bool error = false}) { final overlay = Overlay.maybeOf(context); if (overlay == null || message.trim().isEmpty) return; late final OverlayEntry entry; entry = OverlayEntry( builder: (ctx) => Positioned( top: MediaQuery.paddingOf(ctx).top + 54, left: 32, right: 32, child: IgnorePointer( child: Material( color: Colors.white, elevation: 8, shadowColor: Colors.black26, borderRadius: BorderRadius.circular(10), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), child: Row( children: [ Icon(error ? Icons.error_outline : Icons.check_circle_outline, size: 20, color: error ? kDanger : kWeGreen), const SizedBox(width: 9), Expanded( child: Text(message, textAlign: TextAlign.center, style: const TextStyle(fontSize: 14, color: kInk)), ), ], ), ), ), ), ), ); overlay.insert(entry); Future.delayed(const Duration(milliseconds: 1800), () { if (entry.mounted) entry.remove(); }); } void showPlusMenu(BuildContext context, List<(IconData, String, VoidCallback)> items) { final box = context.findRenderObject() as RenderBox?; final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; final pos = box != null && overlay != null ? RelativeRect.fromRect( Rect.fromPoints(box.localToGlobal(Offset(box.size.width - 8, box.size.height), ancestor: overlay), box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay)), Offset.zero & overlay.size, ) : const RelativeRect.fromLTRB(80, 80, 16, 0); showMenu( context: context, position: pos, color: const Color(0xFF4C4C4C), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), items: [ for (var i = 0; i < items.length; i++) PopupMenuItem( value: i, child: Row( children: [ Icon(items[i].$1, color: Colors.white, size: 20), const SizedBox(width: 12), Text(items[i].$2, style: const TextStyle(color: Colors.white, fontSize: 15)), ], ), ), ], ).then((i) { if (i != null) items[i].$3(); }); }