Initial commit: watch-together phases 0-4 + presence hardening, internet-tested

This commit is contained in:
foxyz committed 2026-09-12 00:13:43 +03:00
commit 427d851a5b
101 files changed
+11326

No files matched your search

@@ -0,0 +1,44 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
Future<String> encodeAvatar(String path) async {
final trimmed = path.trim();
if (trimmed.isEmpty) return '';
final file = File(trimmed);
if (!await file.exists()) {
throw const FormatException(
'avatar file not found — check the path or pick again with Browse');
}
late final List<int> raw;
try {
raw = await file.readAsBytes();
} catch (_) {
throw const FormatException(
'could not read that avatar file — pick it again with Browse');
}
if (raw.isEmpty) {
throw const FormatException(
'that avatar file is empty — pick a PNG, JPG or WebP photo');
}
final decoded = img.decodeImage(Uint8List.fromList(raw));
if (decoded == null) {
throw const FormatException(
'could not read that image — pick a PNG, JPG or WebP photo');
}
final side =
decoded.width < decoded.height ? decoded.width : decoded.height;
final x = (decoded.width - side) ~/ 2;
final y = (decoded.height - side) ~/ 2;
final cropped =
img.copyCrop(decoded, x: x, y: y, width: side, height: side);
final resized = img.copyResize(cropped, width: 128, height: 128);
final jpg = img.encodeJpg(resized, quality: 85);
if (jpg.length > 64 * 1024) {
throw const FormatException(
'that photo is still too large after shrinking — pick a smaller one');
}
return base64Encode(jpg);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
Future<bool?> showKickConfirmDialog(
BuildContext context, String memberName) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Remove $memberName?'),
content: const Text('They can come straight back in.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Remove'),
),
],
),
);
}
@@ -0,0 +1,8 @@
({String mediaPath, String linkUrl}) parseMediaInput(String input) {
final trimmed = input.trim();
final lower = trimmed.toLowerCase();
if (lower.startsWith('http://') || lower.startsWith('https://')) {
return (mediaPath: '', linkUrl: trimmed);
}
return (mediaPath: trimmed, linkUrl: '');
}
@@ -0,0 +1,65 @@
import 'dart:convert';
import 'package:flutter/material.dart';
class MemberAvatar extends StatelessWidget {
final String avatarBase64;
final String name;
final double size;
static final Map<String, MemoryImage> _images = {};
const MemberAvatar({
super.key,
required this.avatarBase64,
required this.name,
this.size = 40,
});
static MemoryImage? _providerFor(String raw) {
if (raw.isEmpty) return null;
var hit = _images[raw];
if (hit != null) return hit;
try {
hit = MemoryImage(base64Decode(raw));
} catch (_) {
return null;
}
if (_images.length > 64) _images.clear();
_images[raw] = hit;
return hit;
}
@override
Widget build(BuildContext context) {
final image = _providerFor(avatarBase64);
final decoration = BoxDecoration(
shape: BoxShape.circle,
);
if (image != null) {
return Container(
decoration: decoration,
child: ClipOval(
child: Image(
image: ResizeImage(height: (size * MediaQuery.of(context).devicePixelRatio).round(), image),
width: size,
height: size,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, __, ___) => _fallback(),
),
),
);
}
return Container(decoration: decoration, child: _fallback());
}
Widget _fallback() => CircleAvatar(
radius: size / 2,
backgroundColor: Colors.grey.shade800,
child: Text(
name.isEmpty ? '?' : name[0].toUpperCase(),
style: TextStyle(fontSize: size * 0.4, color: Colors.white),
),
);
}
@@ -0,0 +1,163 @@
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 Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _pickAvatar,
child: Tooltip(
message: 'Change avatar',
child: CircleAvatar(
radius: 22,
backgroundColor: Colors.grey.shade800,
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());
},
),
),
),
),
const SizedBox(width: 12),
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)),
),
],
),
),
],
);
}
}