Introduce a CodeResolverService to classify user login codes, complete with detailed resolution logic and usability checks. Extend the sync system to handle conflicts via last-write-wins arbitration, with detailed conflict tracking for review. Update file permissions and runtime isolation in Docker to enhance security.
850 lines
27 KiB
Dart
850 lines
27 KiB
Dart
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../api.dart';
|
|
import '../chat_socket.dart';
|
|
import '../main.dart';
|
|
|
|
/// WhatsApp brand colors
|
|
abstract final class WhatsAppColors {
|
|
static const primary = Color(0xFF008069); // WhatsApp Header Green
|
|
static const primaryDark = Color(0xFF075E54); // Classic WhatsApp Dark Green
|
|
static const accent = Color(0xFF00A884); // WhatsApp Bright Accent Green
|
|
static const chatBackground = Color(0xFFEFEAE2); // WhatsApp Chat Wallpaper BG
|
|
static const outgoingBubble = Color(0xFFE7FFDB); // WhatsApp Light Green Bubble
|
|
static const incomingBubble = Color(0xFFFFFFFF); // WhatsApp White Bubble
|
|
static const textPrimary = Color(0xFF111B21); // WhatsApp Main Text Color
|
|
static const textSecondary = Color(0xFF667781); // WhatsApp Muted / Timestamp Color
|
|
static const checkmarkBlue = Color(0xFF53BDEB); // WhatsApp Blue Double Check
|
|
static const dateBadgeBg = Color(0xEEFFFFFF); // WhatsApp Date Header BG
|
|
static const dateBadgeText = Color(0xFF54656F); // WhatsApp Date Header Text
|
|
static const composerBg = Color(0xFFF0F2F5); // WhatsApp Composer Bar BG
|
|
static const iconMuted = Color(0xFF54656F);
|
|
}
|
|
|
|
/// Chat channel overview screen with WhatsApp look and feel.
|
|
class ChatScreen extends StatefulWidget {
|
|
const ChatScreen({super.key, required this.kcId});
|
|
final String kcId;
|
|
|
|
@override
|
|
State<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
Future<List<ChatChannel>>? _future;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_future ??= AppScope.of(context).api.channels(widget.kcId);
|
|
}
|
|
|
|
static const _typeLabels = {
|
|
'GEMEINDE_GRUPPE': 'Gemeinde-Gruppe',
|
|
'DIREKT': 'Direktnachricht',
|
|
'LT_UEBERGREIFEND': 'Leitungsteam',
|
|
'BROADCAST': 'Ankündigungen',
|
|
'GRUPPE': 'Gruppenchat',
|
|
};
|
|
|
|
static const _typeIcons = {
|
|
'GEMEINDE_GRUPPE': Icons.people_alt_rounded,
|
|
'DIREKT': Icons.person_rounded,
|
|
'LT_UEBERGREIFEND': Icons.shield_rounded,
|
|
'BROADCAST': Icons.campaign_rounded,
|
|
'GRUPPE': Icons.groups_rounded,
|
|
};
|
|
|
|
static const _typeColors = {
|
|
'GEMEINDE_GRUPPE': Color(0xFF008069),
|
|
'DIREKT': Color(0xFF2F7CFF),
|
|
'LT_UEBERGREIFEND': Color(0xFF6C5CE7),
|
|
'BROADCAST': Color(0xFFF17C20),
|
|
'GRUPPE': Color(0xFF0984E3),
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Theme(
|
|
data: Theme.of(context).copyWith(
|
|
appBarTheme: const AppBarTheme(
|
|
backgroundColor: WhatsAppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
elevation: 1,
|
|
iconTheme: IconThemeData(color: Colors.white),
|
|
titleTextStyle: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
child: Scaffold(
|
|
backgroundColor: Colors.white,
|
|
appBar: AppBar(
|
|
title: const Text('Chats'),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.search),
|
|
tooltip: 'Suchen',
|
|
onPressed: () {},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.more_vert),
|
|
tooltip: 'Optionen',
|
|
onPressed: () {},
|
|
),
|
|
],
|
|
),
|
|
body: FutureBuilder<List<ChatChannel>>(
|
|
future: _future,
|
|
builder: (context, snap) {
|
|
if (snap.connectionState != ConnectionState.done) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(color: WhatsAppColors.primary),
|
|
);
|
|
}
|
|
if (snap.hasError) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
|
|
const SizedBox(height: 12),
|
|
Text('${snap.error}', textAlign: TextAlign.center),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final channels = snap.data!;
|
|
if (channels.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.chat_bubble_outline, size: 56, color: Colors.grey.shade400),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Keine Kanäle sichtbar',
|
|
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return ListView.separated(
|
|
itemCount: channels.length,
|
|
separatorBuilder: (_, __) => const Divider(
|
|
indent: 80,
|
|
endIndent: 16,
|
|
height: 1,
|
|
thickness: 0.8,
|
|
color: Color(0xFFF0F2F5),
|
|
),
|
|
itemBuilder: (context, i) {
|
|
final c = channels[i];
|
|
final label = c.name?.isNotEmpty == true ? c.name! : (_typeLabels[c.type] ?? c.type);
|
|
final icon = _typeIcons[c.type] ?? Icons.chat_rounded;
|
|
final color = _typeColors[c.type] ?? WhatsAppColors.primary;
|
|
|
|
return ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
leading: CircleAvatar(
|
|
radius: 25,
|
|
backgroundColor: color.withValues(alpha: 0.15),
|
|
child: Icon(icon, color: color, size: 28),
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: WhatsAppColors.textPrimary,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
subtitle: Text(
|
|
_typeLabels[c.type] ?? c.type,
|
|
style: const TextStyle(
|
|
fontSize: 13.5,
|
|
color: WhatsAppColors.textSecondary,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
trailing: const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => _ChannelMessages(
|
|
channelId: c.id,
|
|
title: label,
|
|
subtitle: _typeLabels[c.type] ?? c.type,
|
|
icon: icon,
|
|
iconColor: color,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// WhatsApp-style chat message screen.
|
|
class _ChannelMessages extends StatefulWidget {
|
|
const _ChannelMessages({
|
|
required this.channelId,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.icon,
|
|
required this.iconColor,
|
|
});
|
|
|
|
final String channelId;
|
|
final String title;
|
|
final String subtitle;
|
|
final IconData icon;
|
|
final Color iconColor;
|
|
|
|
@override
|
|
State<_ChannelMessages> createState() => _ChannelMessagesState();
|
|
}
|
|
|
|
class _ChannelMessagesState extends State<_ChannelMessages> {
|
|
final List<ChatMessage> _messages = [];
|
|
final _composer = TextEditingController();
|
|
final _scroll = ScrollController();
|
|
ChatSocket? _socket;
|
|
bool _loading = true;
|
|
String? _loadError;
|
|
String _wsStatus = 'verbinde…';
|
|
bool _hasText = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_composer.addListener(() {
|
|
final hasText = _composer.text.trim().isNotEmpty;
|
|
if (hasText != _hasText) {
|
|
setState(() => _hasText = hasText);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (_socket != null) return;
|
|
final api = AppScope.of(context).api;
|
|
_load(api);
|
|
_socket = ChatSocket(api.chatWsUri())
|
|
..connect(widget.channelId)
|
|
..messages.listen(_onIncoming)
|
|
..status.listen((s) => mounted ? setState(() => _wsStatus = s) : null);
|
|
}
|
|
|
|
Future<void> _load(Api api) async {
|
|
try {
|
|
final history = await api.messages(widget.channelId);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_messages
|
|
..clear()
|
|
..addAll(history);
|
|
_loading = false;
|
|
});
|
|
_jump();
|
|
} catch (e) {
|
|
if (mounted) setState(() { _loadError = '$e'; _loading = false; });
|
|
}
|
|
}
|
|
|
|
void _onIncoming(ChatMessage m) {
|
|
if (!mounted) return;
|
|
setState(() => _messages.add(m));
|
|
_jump();
|
|
}
|
|
|
|
void _jump() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scroll.hasClients) {
|
|
_scroll.animateTo(
|
|
_scroll.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _send() {
|
|
final text = _composer.text.trim();
|
|
if (text.isEmpty) return;
|
|
_socket?.sendMessage(widget.channelId, text);
|
|
_composer.clear();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_socket?.dispose();
|
|
_composer.dispose();
|
|
_scroll.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String _formatTime(String rawDate) {
|
|
final dt = DateTime.tryParse(rawDate)?.toLocal();
|
|
if (dt == null) return rawDate;
|
|
final hour = dt.hour.toString().padLeft(2, '0');
|
|
final minute = dt.minute.toString().padLeft(2, '0');
|
|
return '$hour:$minute';
|
|
}
|
|
|
|
String _formatDateHeader(DateTime date) {
|
|
final now = DateTime.now();
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
final messageDate = DateTime(date.year, date.month, date.day);
|
|
|
|
if (messageDate == today) {
|
|
return 'HEUTE';
|
|
} else if (messageDate == today.subtract(const Duration(days: 1))) {
|
|
return 'GESTERN';
|
|
} else {
|
|
final d = date.day.toString().padLeft(2, '0');
|
|
final m = date.month.toString().padLeft(2, '0');
|
|
return '$d.$m.${date.year}';
|
|
}
|
|
}
|
|
|
|
bool _isSameDay(DateTime a, DateTime b) {
|
|
return a.year == b.year && a.month == b.month && a.day == b.day;
|
|
}
|
|
|
|
Color _getSenderColor(String id) {
|
|
final colors = [
|
|
const Color(0xFF1E88E5),
|
|
const Color(0xFFE53935),
|
|
const Color(0xFF8E24AA),
|
|
const Color(0xFF3949AB),
|
|
const Color(0xFF00897B),
|
|
const Color(0xFFD81B60),
|
|
const Color(0xFFFB8C00),
|
|
const Color(0xFF43A047),
|
|
];
|
|
var hash = 0;
|
|
for (var i = 0; i < id.length; i++) {
|
|
hash = (hash * 31 + id.codeUnitAt(i)) & 0x7FFFFFFF;
|
|
}
|
|
return colors[hash % colors.length];
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final identity = AppScope.of(context).identity;
|
|
|
|
return Theme(
|
|
data: Theme.of(context).copyWith(
|
|
appBarTheme: const AppBarTheme(
|
|
backgroundColor: WhatsAppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
elevation: 1,
|
|
iconTheme: IconThemeData(color: Colors.white),
|
|
titleTextStyle: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
child: Scaffold(
|
|
backgroundColor: WhatsAppColors.chatBackground,
|
|
appBar: AppBar(
|
|
titleSpacing: 0,
|
|
title: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 19,
|
|
backgroundColor: Colors.white.withValues(alpha: 0.2),
|
|
child: Icon(widget.icon, color: Colors.white, size: 22),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
widget.title,
|
|
style: const TextStyle(
|
|
fontSize: 16.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 1),
|
|
Text(
|
|
_wsStatus == 'verbunden' || _wsStatus == 'connected'
|
|
? 'online'
|
|
: _wsStatus,
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
color: Colors.white.withValues(alpha: 0.85),
|
|
fontWeight: FontWeight.normal,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.videocam_rounded),
|
|
tooltip: 'Videoanruf',
|
|
onPressed: () {},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.call_rounded),
|
|
tooltip: 'Anruf',
|
|
onPressed: () {},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.more_vert),
|
|
tooltip: 'Optionen',
|
|
onPressed: () {},
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
// WhatsApp Doodle Pattern Background
|
|
Positioned.fill(
|
|
child: CustomPaint(
|
|
painter: _WhatsAppDoodlePainter(),
|
|
),
|
|
),
|
|
// Chat Content
|
|
Column(
|
|
children: [
|
|
Expanded(child: _buildMessagesList(context, identity)),
|
|
_buildComposer(context),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessagesList(BuildContext context, Identity? identity) {
|
|
if (_loading) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(color: WhatsAppColors.primary),
|
|
);
|
|
}
|
|
if (_loadError != null) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
|
|
const SizedBox(height: 12),
|
|
Text(_loadError!, textAlign: TextAlign.center),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (_messages.isEmpty) {
|
|
return Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: WhatsAppColors.dateBadgeBg,
|
|
borderRadius: BorderRadius.circular(8),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x14000000),
|
|
blurRadius: 3,
|
|
offset: Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: const Text(
|
|
'Nachrichten sind durch End-to-End-Verschlüsselung geschützt.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: WhatsAppColors.dateBadgeText,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final items = <Widget>[];
|
|
DateTime? lastDate;
|
|
|
|
for (var i = 0; i < _messages.length; i++) {
|
|
final m = _messages[i];
|
|
final msgDate = DateTime.tryParse(m.createdAt)?.toLocal();
|
|
|
|
// Insert date divider if day changed
|
|
if (msgDate != null && (lastDate == null || !_isSameDay(lastDate, msgDate))) {
|
|
items.add(_buildDateHeader(_formatDateHeader(msgDate)));
|
|
lastDate = msgDate;
|
|
}
|
|
|
|
final isMe = (identity?.kind == SessionKind.user &&
|
|
m.senderUserId != null &&
|
|
m.senderUserId == identity?.userId) ||
|
|
(identity?.kind == SessionKind.guest &&
|
|
m.senderGuestId != null &&
|
|
m.senderGuestId == identity?.guestId);
|
|
|
|
items.add(_buildMessageBubble(m, isMe));
|
|
}
|
|
|
|
return ListView(
|
|
controller: _scroll,
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
children: items,
|
|
);
|
|
}
|
|
|
|
Widget _buildDateHeader(String text) {
|
|
return Center(
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: WhatsAppColors.dateBadgeBg,
|
|
borderRadius: BorderRadius.circular(7.5),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x10000000),
|
|
blurRadius: 2,
|
|
offset: Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: Text(
|
|
text,
|
|
style: const TextStyle(
|
|
fontSize: 11.5,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.3,
|
|
color: WhatsAppColors.dateBadgeText,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageBubble(ChatMessage m, bool isMe) {
|
|
final timeStr = _formatTime(m.createdAt);
|
|
final senderId = m.senderUserId ?? m.senderGuestId;
|
|
final showSender = !isMe && senderId != null;
|
|
final senderColor = showSender ? _getSenderColor(senderId) : Colors.black;
|
|
|
|
return Align(
|
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
|
minWidth: 80,
|
|
),
|
|
child: Container(
|
|
margin: const EdgeInsets.only(bottom: 4, top: 2),
|
|
decoration: BoxDecoration(
|
|
color: isMe ? WhatsAppColors.outgoingBubble : WhatsAppColors.incomingBubble,
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: const Radius.circular(12),
|
|
topRight: const Radius.circular(12),
|
|
bottomLeft: isMe ? const Radius.circular(12) : const Radius.circular(2),
|
|
bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(12),
|
|
),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x18000000),
|
|
blurRadius: 2,
|
|
offset: Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 10, right: 10, top: 6, bottom: 6),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (showSender) ...[
|
|
Text(
|
|
m.senderGuestId != null ? 'Konfi / Gast' : 'Teamer / Leitung',
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w700,
|
|
color: senderColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
],
|
|
Wrap(
|
|
alignment: WrapAlignment.end,
|
|
crossAxisAlignment: WrapCrossAlignment.bottom,
|
|
spacing: 8,
|
|
runSpacing: 2,
|
|
children: [
|
|
Text(
|
|
m.body,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
color: WhatsAppColors.textPrimary,
|
|
height: 1.25,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
timeStr,
|
|
style: const TextStyle(
|
|
fontSize: 10.5,
|
|
color: WhatsAppColors.textSecondary,
|
|
),
|
|
),
|
|
if (isMe) ...[
|
|
const SizedBox(width: 3),
|
|
const Icon(
|
|
Icons.done_all_rounded,
|
|
size: 15,
|
|
color: WhatsAppColors.checkmarkBlue,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildComposer(BuildContext context) {
|
|
return SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(24),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x14000000),
|
|
blurRadius: 3,
|
|
offset: Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.emoji_emotions_outlined),
|
|
color: WhatsAppColors.iconMuted,
|
|
splashRadius: 20,
|
|
onPressed: () {},
|
|
),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _composer,
|
|
minLines: 1,
|
|
maxLines: 5,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Nachricht',
|
|
hintStyle: TextStyle(
|
|
color: WhatsAppColors.textSecondary,
|
|
fontSize: 15.5,
|
|
),
|
|
border: InputBorder.none,
|
|
isDense: true,
|
|
contentPadding: EdgeInsets.symmetric(vertical: 11, horizontal: 4),
|
|
),
|
|
onSubmitted: (_) => _send(),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.attach_file_rounded),
|
|
color: WhatsAppColors.iconMuted,
|
|
splashRadius: 20,
|
|
onPressed: () {},
|
|
),
|
|
if (!_hasText)
|
|
IconButton(
|
|
icon: const Icon(Icons.camera_alt_rounded),
|
|
color: WhatsAppColors.iconMuted,
|
|
splashRadius: 20,
|
|
onPressed: () {},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
decoration: const BoxDecoration(
|
|
color: WhatsAppColors.accent,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Color(0x28000000),
|
|
blurRadius: 4,
|
|
offset: Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(_hasText ? Icons.send_rounded : Icons.mic_rounded),
|
|
color: Colors.white,
|
|
splashRadius: 24,
|
|
onPressed: _send,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Custom painter for the iconic subtle WhatsApp background doodle canvas.
|
|
class _WhatsAppDoodlePainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = const Color(0xFF4A6B82).withValues(alpha: 0.05)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.3;
|
|
|
|
final fillPaint = Paint()
|
|
..color = const Color(0xFF4A6B82).withValues(alpha: 0.035)
|
|
..style = PaintingStyle.fill;
|
|
|
|
const spacing = 75.0;
|
|
final rows = (size.height / spacing).ceil() + 1;
|
|
final cols = (size.width / spacing).ceil() + 1;
|
|
|
|
for (var r = 0; r < rows; r++) {
|
|
for (var c = 0; c < cols; c++) {
|
|
final x = c * spacing + ((r % 2 == 1) ? spacing / 2 : 0);
|
|
final y = r * spacing;
|
|
final type = (r * 7 + c * 11) % 6;
|
|
|
|
canvas.save();
|
|
canvas.translate(x, y);
|
|
|
|
switch (type) {
|
|
case 0: // Chat bubble doodle
|
|
final rrect = RRect.fromRectAndRadius(
|
|
const Rect.fromLTWH(-10, -8, 20, 16),
|
|
const Radius.circular(5),
|
|
);
|
|
canvas.drawRRect(rrect, fillPaint);
|
|
canvas.drawRRect(rrect, paint);
|
|
break;
|
|
case 1: // Small Star
|
|
final path = Path();
|
|
for (var i = 0; i < 5; i++) {
|
|
final angle = i * 4 * math.pi / 5 - math.pi / 2;
|
|
final px = 8 * math.cos(angle);
|
|
final py = 8 * math.sin(angle);
|
|
if (i == 0) {
|
|
path.moveTo(px, py);
|
|
} else {
|
|
path.lineTo(px, py);
|
|
}
|
|
}
|
|
path.close();
|
|
canvas.drawPath(path, fillPaint);
|
|
canvas.drawPath(path, paint);
|
|
break;
|
|
case 2: // Heart doodle
|
|
final path = Path();
|
|
path.moveTo(0, 4);
|
|
path.cubicTo(-6, -2, -10, -8, 0, -10);
|
|
path.cubicTo(10, -8, 6, -2, 0, 4);
|
|
canvas.drawPath(path, fillPaint);
|
|
canvas.drawPath(path, paint);
|
|
break;
|
|
case 3: // Musical note
|
|
canvas.drawCircle(const Offset(-4, 4), 3, fillPaint);
|
|
canvas.drawCircle(const Offset(-4, 4), 3, paint);
|
|
canvas.drawLine(const Offset(-1, 4), const Offset(-1, -6), paint);
|
|
canvas.drawLine(const Offset(-1, -6), const Offset(5, -4), paint);
|
|
break;
|
|
case 4: // Coffee / cup
|
|
final rrect = RRect.fromRectAndRadius(
|
|
const Rect.fromLTWH(-7, -5, 14, 12),
|
|
const Radius.circular(3),
|
|
);
|
|
canvas.drawRRect(rrect, fillPaint);
|
|
canvas.drawRRect(rrect, paint);
|
|
canvas.drawArc(
|
|
const Rect.fromLTWH(4, -3, 6, 6),
|
|
-math.pi / 2,
|
|
math.pi,
|
|
false,
|
|
paint,
|
|
);
|
|
break;
|
|
case 5: // Clock / circle
|
|
canvas.drawCircle(Offset.zero, 7, fillPaint);
|
|
canvas.drawCircle(Offset.zero, 7, paint);
|
|
canvas.drawLine(Offset.zero, const Offset(0, -4), paint);
|
|
canvas.drawLine(Offset.zero, const Offset(3, 0), paint);
|
|
break;
|
|
}
|
|
|
|
canvas.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|