Files
sepot/app/lib/widgets/avatar.dart
T
Julius Joska 5246a97db2 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.
2026-10-03 22:44:53 +02:00

160 lines
4.8 KiB
Dart

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';
/// 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;
/// 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.seed,
this.verified = false,
this.ring,
});
@override
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: [
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),
),
),
],
);
}
/// 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();
if (parts.length >= 2) {
return '${parts[0].characters.first}${parts[1].characters.first}'
.toUpperCase();
}
if (parts.isEmpty) return '?';
return parts[0].characters.take(2).toString().toUpperCase();
}
}
/// 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),
);
}
}