5246a97db2
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.
160 lines
4.8 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|