forked from Foxyz/sync_replay_project
Initial commit: watch-together phases 0-4 + presence hardening, internet-tested
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user