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.
317 lines
9.9 KiB
Dart
317 lines
9.9 KiB
Dart
/// Stavební prvky profilu kontaktu a skupiny — ať oba vypadají a chovají
|
|
/// se stejně.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:sepot/core/l10n/l10n.dart';
|
|
import 'package:sepot/core/theme/app_theme.dart';
|
|
import 'package:sepot/features/conversation/image_viewer_screen.dart';
|
|
import 'package:sepot/models/message.dart';
|
|
|
|
/// Výsledek profilu otevřeného z chatu: co má chat udělat po návratu.
|
|
enum ProfileResult { openSearch }
|
|
|
|
/// Horní část profilu: zpět, velký avatar, jméno a řádky pod ním.
|
|
class ProfileHero extends StatelessWidget {
|
|
const ProfileHero({
|
|
super.key,
|
|
required this.avatar,
|
|
required this.title,
|
|
required this.lines,
|
|
this.onTitleTap,
|
|
});
|
|
|
|
final Widget avatar;
|
|
final String title;
|
|
final List<Widget> lines;
|
|
final VoidCallback? onTitleTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
color: SepotColors.hero,
|
|
padding: EdgeInsets.fromLTRB(
|
|
4, MediaQuery.paddingOf(context).top + 4, 4, 22),
|
|
child: Column(
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: IconButton(
|
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
|
icon: const Icon(Icons.arrow_back_rounded),
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
),
|
|
),
|
|
avatar,
|
|
const SizedBox(height: 14),
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: onTitleTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 26,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.3,
|
|
),
|
|
),
|
|
),
|
|
if (onTitleTap != null) ...[
|
|
const SizedBox(width: 6),
|
|
const Icon(Icons.edit_outlined,
|
|
size: 18, color: SepotColors.textSecondary),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
for (final line in lines)
|
|
Padding(padding: const EdgeInsets.only(top: 4), child: line),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Čtveřice rychlých akcí pod hlavičkou profilu.
|
|
class ProfileAction extends StatelessWidget {
|
|
const ProfileAction({
|
|
super.key,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.accent = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback? onTap;
|
|
final bool accent;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = accent ? SepotColors.ember : SepotColors.textPrimary;
|
|
return Expanded(
|
|
child: Material(
|
|
color: SepotColors.surface,
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(18),
|
|
onTap: onTap == null
|
|
? null
|
|
: () {
|
|
HapticFeedback.selectionClick();
|
|
onTap!();
|
|
},
|
|
child: SizedBox(
|
|
height: 70,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, size: 22, color: color),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12, fontWeight: FontWeight.w500, color: color),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Zaoblená karta se sekcí profilu; řádky odděluje tenká linka.
|
|
class ProfileCard extends StatelessWidget {
|
|
const ProfileCard({super.key, required this.children, this.title, this.trailing});
|
|
|
|
final String? title;
|
|
final String? trailing;
|
|
final List<Widget> children;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: SepotColors.surface,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (title != null)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(title!,
|
|
style: const TextStyle(
|
|
fontSize: 15, fontWeight: FontWeight.w700)),
|
|
),
|
|
if (trailing != null)
|
|
Text(trailing!,
|
|
style: const TextStyle(
|
|
fontSize: 13, color: SepotColors.textSecondary)),
|
|
],
|
|
),
|
|
),
|
|
for (var i = 0; i < children.length; i++) ...[
|
|
if (i > 0 && title == null)
|
|
const Divider(height: 1, indent: 16, color: SepotColors.divider),
|
|
children[i],
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Řádek karty: popisek vlevo, hodnota vpravo, volitelně šipka.
|
|
class ProfileRow extends StatelessWidget {
|
|
const ProfileRow({
|
|
super.key,
|
|
required this.label,
|
|
this.value,
|
|
this.leading,
|
|
this.subtitle,
|
|
this.onTap,
|
|
this.danger = false,
|
|
this.trailing,
|
|
});
|
|
|
|
final String label;
|
|
final String? value;
|
|
final Widget? leading;
|
|
final String? subtitle;
|
|
final VoidCallback? onTap;
|
|
final bool danger;
|
|
final Widget? trailing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
|
child: Row(
|
|
children: [
|
|
if (leading != null) ...[leading!, const SizedBox(width: 12)],
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: danger ? FontWeight.w600 : FontWeight.w400,
|
|
color: danger
|
|
? SepotColors.error
|
|
: SepotColors.textPrimary)),
|
|
if (subtitle != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: Text(subtitle!,
|
|
style: const TextStyle(
|
|
fontSize: 13, color: SepotColors.textSecondary)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (value != null)
|
|
Text(value!,
|
|
style: const TextStyle(
|
|
fontSize: 14, color: SepotColors.textSecondary)),
|
|
?trailing,
|
|
if (onTap != null && !danger && trailing == null) ...[
|
|
const SizedBox(width: 6),
|
|
const Icon(Icons.chevron_right_rounded,
|
|
size: 20, color: SepotColors.textMuted),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Mřížka sdílených fotek (inline náhledy z E2E zpráv, nic se nestahuje).
|
|
class SharedMediaCard extends StatelessWidget {
|
|
const SharedMediaCard({super.key, required this.messages});
|
|
|
|
/// Všechny zprávy konverzace — fotky si vybere sama.
|
|
final List<Message> messages;
|
|
|
|
static const _shown = 8;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final images = messages.where(isViewableImage).toList().reversed.toList();
|
|
if (images.isEmpty) return const SizedBox.shrink();
|
|
final extra = images.length - _shown;
|
|
final visible = images.take(_shown).toList();
|
|
|
|
return ProfileCard(
|
|
title: context.l10n.profileSharedMedia,
|
|
trailing: '${images.length}',
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
|
child: GridView.count(
|
|
crossAxisCount: 4,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 3,
|
|
crossAxisSpacing: 3,
|
|
children: [
|
|
for (var i = 0; i < visible.length; i++)
|
|
GestureDetector(
|
|
onTap: () =>
|
|
ImageViewerScreen.open(context, visible[i], messages),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (visible[i].attachment?.thumbnail != null)
|
|
Image.memory(visible[i].attachment!.thumbnail!,
|
|
fit: BoxFit.cover, gaplessPlayback: true)
|
|
else
|
|
const ColoredBox(color: SepotColors.surfaceVariant),
|
|
if (i == _shown - 1 && extra > 0)
|
|
ColoredBox(
|
|
color: Colors.black54,
|
|
child: Center(
|
|
child: Text('+$extra',
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|