feat(app): redesigned interface, polls, drafts, forwarding and mentions
A new look across the app and a set of everyday messaging features. Interface - Home screen with Chats, Contacts and Settings tabs, a large title, search across chats and message text (diacritics-insensitive), filters and pinned chats. - Conversation view with a translucent header and composer, grouped bubbles, author avatars and colours in groups, swipe to reply, an "N new messages" marker, a jump-to-latest button and in-chat search. - Contact and group profiles: verification, shared photos, groups in common, disappearing messages, mute, block; members, admin badges, adding people, rename and leave for groups. - Full-screen photo viewer that pages through the whole conversation. - A stable avatar colour per person and a clearly visible read state. Messaging - Polls in chats and groups, single or multiple choice, with votes that can be changed or withdrawn; older clients see them as text. - Drafts survive leaving a chat and show in the chat list. - Forwarding of messages, photos, files and voice messages. - Notes to self: a local-only, encrypted chat that never reaches the relay. - @mentions in groups that notify even when the group is muted. - Typing indicators sent as ephemeral messages, opt-in and mutual like read receipts. - Delivery and read times per message and per group member. - Profile photos, cropped, resized and stripped of metadata, delivered encrypted to contacts. - Voice message playback at 1.5x and 2x. - Call buttons in 1:1 chats and call log entries. Fixes - Muted groups no longer notify: muting was checked against the author's chat instead of the group. - A blocked contact could unblock itself by changing its delivery address. - Read receipts no longer regress to delivered when a late delivery receipt arrives. - A message marked as failed corrects itself once it is delivered. - Double ticks in groups now mean delivered to every member.
This commit is contained in:
+123
-54
@@ -1,73 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
|
||||
/// Generovaný avatar z iniciál
|
||||
class SepotAvatar extends StatelessWidget {
|
||||
/// Avatar člověka: jeho profilová fotka, jinak iniciály na barevném tónu.
|
||||
///
|
||||
/// Tón se odvozuje ze [seed] (otisk klíče), ne ze jména — přejmenovaný
|
||||
/// kontakt si barvu nechá a dva lidé se stejným jménem se od sebe liší.
|
||||
/// Podle téhož otisku se hledá i fotka.
|
||||
class SepotAvatar extends ConsumerWidget {
|
||||
final String name;
|
||||
final double size;
|
||||
final bool isOnline;
|
||||
|
||||
/// Stabilní identifikátor pro tón (fingerprint). Bez něj se použije jméno.
|
||||
final String? seed;
|
||||
|
||||
/// Zelený odznak ověřeného kontaktu v pravém dolním rohu.
|
||||
final bool verified;
|
||||
|
||||
/// Kroužek kolem avataru (připnutý chat s nepřečtenou zprávou apod.).
|
||||
final Color? ring;
|
||||
|
||||
const SepotAvatar({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.size = 48,
|
||||
this.isOnline = false,
|
||||
this.seed,
|
||||
this.verified = false,
|
||||
this.ring,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initials = _getInitials(name);
|
||||
final color = _colorFromName(name);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tint = SepotTint.of(seed ?? name);
|
||||
final badge = (size * 0.34).clamp(12.0, 30.0);
|
||||
final photo =
|
||||
seed == null ? null : ref.watch(avatarProvider(seed!)).valueOrNull;
|
||||
final px = (size * MediaQuery.devicePixelRatioOf(context)).round();
|
||||
|
||||
Widget circle = Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: tint.background),
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photo != null
|
||||
? Image.memory(
|
||||
photo,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
// Dekódovat jen v potřebné velikosti — seznam má desítky avatarů.
|
||||
cacheWidth: px,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
: Text(
|
||||
initialsOf(name),
|
||||
style: TextStyle(
|
||||
color: tint.ink,
|
||||
fontSize: size * 0.34,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (ring != null) {
|
||||
circle = Container(
|
||||
padding: const EdgeInsets.all(2.5),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: ring!, width: 2),
|
||||
),
|
||||
child: circle,
|
||||
);
|
||||
}
|
||||
if (!verified) return circle;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
// Nokturno: tichý tmavý podklad, barva jen v iniciálách
|
||||
color: SepotColors.surfaceVariant,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: size * 0.35,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
circle,
|
||||
Positioned(
|
||||
right: -1,
|
||||
bottom: -1,
|
||||
child: Container(
|
||||
width: badge,
|
||||
height: badge,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.sage,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: SepotColors.background, width: 2),
|
||||
),
|
||||
child: Icon(Icons.check_rounded,
|
||||
size: badge * 0.62, color: SepotColors.background),
|
||||
),
|
||||
),
|
||||
if (isOnline)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: size * 0.28,
|
||||
height: size * 0.28,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.success,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: SepotColors.background,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getInitials(String name) {
|
||||
/// Iniciály pro avatar — veřejné, ať je sdílí i kolečka hlasujících apod.
|
||||
static String initialsOf(String name) {
|
||||
// Prázdné díly odfiltrovat — dvojitá mezera ve jméně nesmí shodit render.
|
||||
// Grafémy (characters), ne code units — jméno začínající emoji by se
|
||||
// jinak rozpůlilo na rozbitý surrogate.
|
||||
final parts =
|
||||
name.trim().split(' ').where((p) => p.isNotEmpty).toList();
|
||||
final parts = name.trim().split(' ').where((p) => p.isNotEmpty).toList();
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[0].characters.first}${parts[1].characters.first}'
|
||||
.toUpperCase();
|
||||
@@ -75,16 +112,48 @@ class SepotAvatar extends StatelessWidget {
|
||||
if (parts.isEmpty) return '?';
|
||||
return parts[0].characters.take(2).toString().toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
Color _colorFromName(String name) {
|
||||
// Jen tlumené tóny palety — žádné syté cizí barvy
|
||||
final colors = [
|
||||
SepotColors.ember,
|
||||
SepotColors.mist,
|
||||
SepotColors.sage,
|
||||
SepotColors.warning,
|
||||
SepotColors.textSecondary,
|
||||
];
|
||||
return colors[name.hashCode.abs() % colors.length];
|
||||
/// Avatar skupiny: zaoblený čtverec s ikonou, ať se na první pohled liší
|
||||
/// od člověka.
|
||||
/// Avatar poznámek pro sebe — vlastní tvar i barva, ať se neplete s lidmi.
|
||||
class NotesAvatar extends StatelessWidget {
|
||||
const NotesAvatar({super.key, this.size = 48});
|
||||
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.ember.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(size * 0.32),
|
||||
),
|
||||
child: Icon(Icons.bookmark_rounded,
|
||||
size: size * 0.5, color: SepotColors.ember),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SepotGroupAvatar extends StatelessWidget {
|
||||
const SepotGroupAvatar({super.key, required this.seed, this.size = 48});
|
||||
|
||||
final String seed;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tint = SepotTint.of(seed);
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: tint.background,
|
||||
borderRadius: BorderRadius.circular(size * 0.32),
|
||||
),
|
||||
child: Icon(Icons.groups_rounded, size: size * 0.5, color: tint.ink),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user