1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash

The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
  Host cards (hosting no longer takes media), status-ring avatar stack
  with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
  draggable corner-snapping mini player, or fullscreen (window_manager,
  party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
  PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
  mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
  = the new SchedulePage; Settings = left nav over the existing panels.

Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
  "avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
  /a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
  cached by hash in the app (AvatarCache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:23:46 +03:00
1 parent f790470935
commit d29c12e4f0
62 files changed
+5777 -3572

No files matched your search

+87 -241
View File
@@ -1,285 +1,131 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/connect_prefs.dart';
import '../../state/session_state.dart';
import '../../widgets/avatar_pipeline.dart';
import 'ui.dart';
/// Display name + avatar, shared by the Home profile row and Settings.
/// Backed by [SessionState.lastForm]; every change is persisted. The
/// avatar goes through [encodeAvatar] (the same shrink the server gets)
/// before its path is stored, and the encoded bytes are the preview.
/// Colours offered in the profile card; the first is the default.
const profileColors = <Color>[
Colors.deepPurple,
Colors.indigo,
Colors.teal,
Colors.pink,
Colors.orange,
Colors.blueGrey,
];
const _keyPicture = 'avatarPng';
const _keyColor = 'profileColor';
/// Sends a mid-session name/picture change (null = unchanged, empty
/// avatar = removed). Swappable so tests can watch it.
typedef ProfilePush = void Function({String? name, String? avatar});
/// The one place a profile change reaches the server.
void pushProfileToSession({String? name, String? avatar}) {
final session = SessionState.instance;
if (session.route.peek() != AppRoute.session) return;
session.coordinator.updateProfile(name: name, avatar: avatar);
}
/// Display name, picture and colour of this app's user. Backed by
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
/// 256 px PNG and the colour); every change is persisted, and while in a
/// server it is pushed through [push] so everyone sees it at once.
class Profile {
static final Profile instance = Profile._();
Profile._();
final name = signal('');
final preview = signal<Uint8List?>(null);
/// The cropped PNG; null shows the initial on [color].
final picture = signal<Uint8List?>(null);
final color = signal<Color>(profileColors.first);
final error = signal<String?>(null);
final picking = signal(false);
ProfilePush push = pushProfileToSession;
bool _loaded = false;
Timer? _nameTimer;
void ensureLoaded() {
/// Reads the stored profile once (the app shell starts it). Old installs
/// stored a picture path: it is shrunk through [encodeAvatar] once and
/// kept as bytes. A caller during the first read does not wait for it
/// (no shared Future: tests run each in its own zone).
Future<void> ensureLoaded() async {
if (_loaded) return;
_loaded = true;
final f = SessionState.instance.lastForm;
name.value = f.name;
_refreshPreview(f.avatarPath);
await _load();
}
Future<void> _refreshPreview(String path) async {
Future<void> _load() async {
final form = SessionState.instance.lastForm;
name.value = form.name;
try {
final b64 = await encodeAvatar(path);
preview.value = b64.isEmpty ? null : base64Decode(b64);
} catch (_) {
preview.value = null;
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = box.get(_keyPicture);
final c = box.get(_keyColor);
if (c is int) color.value = Color(c);
if (png is Uint8List && png.isNotEmpty) {
picture.value = png;
} else if (form.avatarPath.isNotEmpty) {
final b64 = await encodeAvatar(form.avatarPath);
if (b64.isNotEmpty) picture.value = base64Decode(b64);
form.avatarPath = '';
await _save();
}
} catch (e) {
// No store (tests) or an unreadable old picture: start without.
debugPrint('profile load: $e');
}
}
/// The picture as sent in hello and profile updates; '' when none.
String avatarBase64() {
final png = picture.peek();
return png == null ? '' : avatarWireBase64(png);
}
void setName(String value) {
name.value = value.trim();
SessionState.instance.lastForm.name = value.trim();
_save();
final v = value.trim();
if (v.isEmpty || v == name.peek()) return;
name.value = v;
SessionState.instance.lastForm.name = v;
unawaited(_save());
// Typing renames once it settles, not on every key.
_nameTimer?.cancel();
_nameTimer = Timer(const Duration(milliseconds: 600), () => push(name: name.peek()));
}
Future<void> pickAvatar() async {
if (picking.peek()) return;
picking.value = true;
Future<void> setPicture(Uint8List? png) async {
picture.value = png;
error.value = null;
await _save();
try {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick avatar image',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
);
} catch (_) {
error.value = 'file dialog unavailable — try again';
return;
}
final path = files.isEmpty ? null : files.first.path;
if (path == null) return;
final String b64;
try {
b64 = await encodeAvatar(path);
} on FormatException catch (e) {
error.value = e.message;
return;
} catch (_) {
error.value = 'could not read that image — pick a PNG, JPG or WebP photo';
return;
}
SessionState.instance.lastForm.avatarPath = path;
preview.value = b64.isEmpty ? null : base64Decode(b64);
await _save();
} finally {
picking.value = false;
push(avatar: png == null ? '' : avatarWireBase64(png));
} on FormatException catch (e) {
error.value = e.message;
}
}
Future<void> clearAvatar() async {
SessionState.instance.lastForm.avatarPath = '';
preview.value = null;
error.value = null;
Future<void> setColor(Color c) async {
color.value = c;
await _save();
}
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = picture.peek();
await box.putAll({
_keyPicture: png ?? Uint8List(0),
_keyColor: color.peek().toARGB32(),
});
} catch (_) {
error.value = 'could not save profile — try again';
}
}
}
/// Avatar circle with the initial as fallback.
class ProfileAvatar extends StatelessWidget {
final double radius;
const ProfileAvatar({super.key, this.radius = 20});
@override
Widget build(BuildContext context) {
final p = Profile.instance;
return SignalBuilder(builder: (context) {
final img = p.preview.value;
final n = p.name.value;
return CircleAvatar(
radius: radius,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
backgroundImage: img != null ? MemoryImage(img) : null,
child: img != null
? null
: p.picking.value
? SizedBox(
width: radius * 0.8,
height: radius * 0.8,
child: const CircularProgressIndicator(strokeWidth: 2))
: Text(n.isEmpty ? '?' : n[0].toUpperCase(),
style: TextStyle(fontSize: radius * 0.8)),
);
});
}
}
/// Avatar (tap to change) + name field. Used inline in Settings and in
/// the Home profile dialog.
class ProfileEditor extends StatefulWidget {
final bool autofocus;
final VoidCallback? onSubmitted;
const ProfileEditor({super.key, this.autofocus = false, this.onSubmitted});
@override
State<ProfileEditor> createState() => _ProfileEditorState();
}
class _ProfileEditorState extends State<ProfileEditor> {
late final _name = TextEditingController(text: Profile.instance.name.peek());
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final p = Profile.instance;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Tooltip(
message: 'Change avatar',
child: InkWell(
customBorder: const CircleBorder(),
onTap: p.pickAvatar,
child: const ProfileAvatar(radius: 36),
),
),
const SizedBox(width: kGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
autofocus: widget.autofocus,
decoration: fieldDecoration('Display name'),
onChanged: p.setName,
onSubmitted: (_) => widget.onSubmitted?.call(),
),
const SizedBox(height: 4),
SignalBuilder(builder: (context) {
final err = p.error.value;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
TextButton(
onPressed: p.pickAvatar,
child: const Text('Change avatar')),
if (p.preview.value != null)
TextButton(
onPressed: p.clearAvatar,
child: const Text('Remove avatar')),
if (err != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(err,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.error)),
),
],
);
}),
],
),
),
],
);
}
}
Future<void> showProfileDialog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
title: const Text('Profile'),
content: SizedBox(
width: 380,
child: ProfileEditor(
autofocus: true,
onSubmitted: () => Navigator.pop(context),
),
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context), child: const Text('Done')),
],
),
);
}
/// Home's top-left: avatar + name, click to edit.
class ProfileChip extends StatelessWidget {
/// One line and a small avatar, for the Home top bar.
final bool dense;
/// Just the avatar, for very narrow windows.
final bool avatarOnly;
const ProfileChip({super.key, this.dense = false, this.avatarOnly = false});
@override
Widget build(BuildContext context) {
final p = Profile.instance;
return Tooltip(
message: 'Edit profile',
child: InkWell(
borderRadius: BorderRadius.circular(kRadius),
onTap: () => showProfileDialog(context),
child: avatarOnly
? const Padding(padding: EdgeInsets.all(4), child: ProfileAvatar(radius: 14))
: Padding(
padding: dense
? const EdgeInsets.fromLTRB(4, 4, 10, 4)
: const EdgeInsets.fromLTRB(6, 6, 14, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ProfileAvatar(radius: dense ? 14 : 20),
SizedBox(width: dense ? 8 : 12),
SignalBuilder(builder: (context) {
final n = p.name.value;
final t = Theme.of(context).textTheme;
final name = Text(n.isEmpty ? 'Set your name' : n,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: (dense ? t.titleSmall : t.titleMedium)
?.copyWith(color: n.isEmpty ? Colors.grey : null));
if (dense) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200), child: name);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
name,
Text('Edit profile',
style: t.bodySmall?.copyWith(color: Colors.grey)),
],
);
}),
],
),
),
),
);
}
}