Frameless (window_manager, hidden titlebar): chrome bar on welcome, drag-to-move appbar + window buttons on connect, drag layer + window buttons in session controls; min size 160x90; volume-scroll off for drag; session maximize = fullscreen toggle. Verified on KDE Wayland. Delete legacy/minimal/nosheet bisect screens; session routes direct (black-screen fix verified incl. WAN). Avatar: pipeline now 128px aspect-kept PNG (was cropped JPEG); profile card centered 64px hover-to-change; member avatar drops ResizeImage downscale. NOTE open: pipeline 512 KiB check vs server 64 KiB enforcement mismatch. Fixes: idle warn/unready skipped while PLAYING; sendChat drops blanks; one chat row per send (drop textless server feed echo); chat field keeps focus after send. Playback libass true; sdk floor 3.13. Handoff 9-11: 9+10 fixed, 11 jumpy seek still open. Docs: handoff/roadmap/protocol record all of the above; few-commits preference going forward.
167 lines
5.1 KiB
Dart
167 lines
5.1 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../state/session_state.dart';
|
|
import 'avatar_pipeline.dart';
|
|
|
|
/// Profile card for the connect screen.
|
|
///
|
|
/// Lives in a fixed corner card outside the scrolling form, so Host/Join
|
|
/// field changes below never move it. Name edits write back to
|
|
/// [SessionState.lastForm] on every change (persisted on submit); avatar
|
|
/// changes by tapping the circle, validated through [encodeAvatar] before
|
|
/// the path is stored, with the encoded bytes shown as the preview.
|
|
class ConnectProfileCard extends StatefulWidget {
|
|
final TextEditingController name;
|
|
const ConnectProfileCard({super.key, required this.name});
|
|
|
|
@override
|
|
State<ConnectProfileCard> createState() => _ConnectProfileCardState();
|
|
}
|
|
|
|
class _ConnectProfileCardState extends State<ConnectProfileCard> {
|
|
late String _avatarPath;
|
|
Uint8List? _preview;
|
|
String? _error;
|
|
bool _picking = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_avatarPath = SessionState.instance.lastForm.avatarPath;
|
|
_refreshPreview();
|
|
}
|
|
|
|
Future<void> _refreshPreview() async {
|
|
String b64;
|
|
try {
|
|
b64 = await encodeAvatar(_avatarPath);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
if (!mounted) return;
|
|
setState(() => _preview = b64.isEmpty ? null : base64Decode(b64));
|
|
}
|
|
|
|
Future<void> _persistProfile() async {
|
|
try {
|
|
await SessionState.instance.saveForm();
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() => _error = 'could not save profile — try again');
|
|
}
|
|
}
|
|
|
|
Future<void> _pickAvatar() async {
|
|
if (_picking) return;
|
|
setState(() {
|
|
_picking = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final List<PlatformFile> files;
|
|
try {
|
|
files = await FilePicker.pickFiles(
|
|
dialogTitle: 'Pick avatar image',
|
|
type: FileType.custom,
|
|
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
|
|
);
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() => _error = 'file dialog unavailable — try again');
|
|
return;
|
|
}
|
|
if (files.isEmpty) return;
|
|
final path = files.first.path;
|
|
if (path == null || !mounted) return;
|
|
late final String b64;
|
|
try {
|
|
b64 = await encodeAvatar(path);
|
|
} on FormatException catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.message);
|
|
return;
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() => _error = 'could not read that image — pick a PNG, JPG or WebP photo');
|
|
return;
|
|
}
|
|
_avatarPath = path;
|
|
SessionState.instance.lastForm.avatarPath = path;
|
|
await _persistProfile();
|
|
if (!mounted) return;
|
|
setState(() => _preview = b64.isEmpty ? null : base64Decode(b64));
|
|
} finally {
|
|
if (mounted) setState(() => _picking = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: .center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
spacing: 12,
|
|
children: [
|
|
ClipOval(
|
|
clipBehavior: .antiAlias,
|
|
child: Tooltip(
|
|
message: 'Change avatar',
|
|
child: Stack(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 64,
|
|
backgroundColor: Colors.transparent,
|
|
backgroundImage: _preview != null ? MemoryImage(_preview!) : null,
|
|
child: _preview != null
|
|
? null
|
|
: _picking
|
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
|
: ListenableBuilder(
|
|
listenable: widget.name,
|
|
builder: (context, _) {
|
|
final t = widget.name.text.trim();
|
|
return Text(t.isEmpty ? '?' : t[0].toUpperCase());
|
|
},
|
|
),
|
|
),
|
|
Positioned.fill(
|
|
child: Material(
|
|
type: .transparency,
|
|
child: InkWell(onTap: _pickAvatar),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 180,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: widget.name,
|
|
decoration: const InputDecoration(labelText: 'Display name', isDense: true),
|
|
onChanged: (_) {
|
|
SessionState.instance.lastForm.name = widget.name.text.trim();
|
|
},
|
|
onSubmitted: (_) => _persistProfile(),
|
|
),
|
|
if (_error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(_error!, style: const TextStyle(fontSize: 11)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|