feat/refactor: добавил индикатор при просмотре кружков/гс, сделал запись кружков нормальным членом общества
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../widgets/glossy_pill.dart';
|
||||
|
||||
import '../../../../core/config/app_video_note_quality.dart';
|
||||
import '../../../../core/media/native_video_note_recorder.dart';
|
||||
@@ -18,12 +24,14 @@ class VideoNoteController {
|
||||
required this.isMounted,
|
||||
required this.onRecorded,
|
||||
required this.formatElapsed,
|
||||
required this.bottomInset,
|
||||
});
|
||||
|
||||
final BuildContext Function() contextOf;
|
||||
final bool Function() isMounted;
|
||||
final Future<void> Function(File file, int durationMs) onRecorded;
|
||||
final String Function(int ms) formatElapsed;
|
||||
final double Function() bottomInset;
|
||||
|
||||
final NativeVideoNoteRecorder _rec = NativeVideoNoteRecorder();
|
||||
final ValueNotifier<bool> _videoNoteMode = ValueNotifier(false);
|
||||
@@ -32,6 +40,9 @@ class VideoNoteController {
|
||||
final ValueNotifier<bool> _isRecording = ValueNotifier(false);
|
||||
final ValueNotifier<int> _elapsedMs = ValueNotifier(0);
|
||||
final ValueNotifier<double> _cancelDrag = ValueNotifier(0);
|
||||
final ValueNotifier<bool> _locked = ValueNotifier(false);
|
||||
final ValueNotifier<double> _lockDrag = ValueNotifier(0);
|
||||
final ValueNotifier<bool> _flashOn = ValueNotifier(false);
|
||||
final Stopwatch _stopwatch = Stopwatch();
|
||||
Timer? _timer;
|
||||
bool _cancelled = false;
|
||||
@@ -40,11 +51,29 @@ class VideoNoteController {
|
||||
bool _switchingCamera = false;
|
||||
|
||||
bool get _front => _frontOverride ?? !AppVideoNoteRearCamera.current.value;
|
||||
OverlayEntry? _overlay;
|
||||
|
||||
static const int maxMs = 60000;
|
||||
static const double _lockThreshold = 90;
|
||||
|
||||
ValueListenable<bool> get videoNoteMode => _videoNoteMode;
|
||||
ValueListenable<bool> get camReady => _camReady;
|
||||
ValueListenable<bool> get isRecording => _isRecording;
|
||||
ValueListenable<int> get elapsedMs => _elapsedMs;
|
||||
ValueListenable<double> get cancelDrag => _cancelDrag;
|
||||
ValueListenable<bool> get locked => _locked;
|
||||
ValueListenable<double> get lockDrag => _lockDrag;
|
||||
ValueListenable<bool> get flashOn => _flashOn;
|
||||
ValueListenable<int?> get textureId => _textureId;
|
||||
|
||||
bool get flashAvailable => _rec.hasFlash || _stub;
|
||||
|
||||
bool get cameraControlsAvailable => true;
|
||||
|
||||
Future<void> toggleFlash() async {
|
||||
final next = !_flashOn.value;
|
||||
_flashOn.value = _stub ? next : await _rec.setTorch(next);
|
||||
Haptics.tap();
|
||||
}
|
||||
|
||||
Future<void> toggleMode() async {
|
||||
final toVideo = !_videoNoteMode.value;
|
||||
@@ -57,10 +86,20 @@ class VideoNoteController {
|
||||
}
|
||||
}
|
||||
|
||||
bool get _stub => !_rec.isAvailable;
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
if (_stub) {
|
||||
_camReady.value = true;
|
||||
_textureId.value = null;
|
||||
return;
|
||||
}
|
||||
if (_rec.textureId != null) return;
|
||||
if (!_rec.isAvailable) {
|
||||
if (isMounted()) showCustomNotification(contextOf(), 'Камера недоступна');
|
||||
if (!await _rec.requestPermission()) {
|
||||
_videoNoteMode.value = false;
|
||||
if (isMounted()) {
|
||||
showCustomNotification(contextOf(), 'Нет доступа к камере');
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -90,24 +129,24 @@ class VideoNoteController {
|
||||
Future<void> _disposeCamera() async {
|
||||
_camReady.value = false;
|
||||
_textureId.value = null;
|
||||
await _rec.dispose();
|
||||
if (!_stub) await _rec.dispose();
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
if (_isRecording.value) return;
|
||||
_stopRequested = false;
|
||||
if (_rec.textureId == null) {
|
||||
if (!_stub && _rec.textureId == null) {
|
||||
await _initCamera();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final ok = await _rec.start();
|
||||
final ok = _stub || await _rec.start();
|
||||
if (!ok) {
|
||||
_isRecording.value = false;
|
||||
return;
|
||||
}
|
||||
if (!isMounted()) {
|
||||
await _rec.stop();
|
||||
if (!_stub) await _rec.stop();
|
||||
return;
|
||||
}
|
||||
_stopwatch
|
||||
@@ -115,14 +154,17 @@ class VideoNoteController {
|
||||
..start();
|
||||
_elapsedMs.value = 0;
|
||||
_cancelDrag.value = 0;
|
||||
_lockDrag.value = 0;
|
||||
_locked.value = false;
|
||||
_cancelled = false;
|
||||
_isRecording.value = true;
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
Haptics.send();
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||
_elapsedMs.value = _stopwatch.elapsedMilliseconds;
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
||||
final ms = _stopwatch.elapsedMilliseconds;
|
||||
_elapsedMs.value = ms >= maxMs ? maxMs : ms;
|
||||
if (ms >= maxMs) unawaited(stop(cancel: false));
|
||||
});
|
||||
_showOverlay();
|
||||
if (_stopRequested) {
|
||||
_stopRequested = false;
|
||||
await stop(cancel: false);
|
||||
@@ -133,7 +175,26 @@ class VideoNoteController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _stubClip() async {
|
||||
try {
|
||||
final data = await rootBundle.load('assets/debug/fake_video_note.mp4');
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File(
|
||||
'${dir.path}/note_stub_${DateTime.now().millisecondsSinceEpoch}.mp4',
|
||||
);
|
||||
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
|
||||
return file.path;
|
||||
} catch (e) {
|
||||
logger.w('VideoNoteController._stubClip: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> flipCamera() async {
|
||||
if (_stub) {
|
||||
Haptics.tap();
|
||||
return;
|
||||
}
|
||||
if (!_camReady.value || _switchingCamera) return;
|
||||
_switchingCamera = true;
|
||||
try {
|
||||
@@ -148,7 +209,18 @@ class VideoNoteController {
|
||||
}
|
||||
|
||||
void handleDrag(Offset offsetFromOrigin) {
|
||||
if (!_isRecording.value) return;
|
||||
if (!_isRecording.value || _locked.value) return;
|
||||
|
||||
final lock = (-offsetFromOrigin.dy / _lockThreshold).clamp(0.0, 1.0);
|
||||
_lockDrag.value = lock;
|
||||
if (lock >= 1.0) {
|
||||
_locked.value = true;
|
||||
_lockDrag.value = 0;
|
||||
_cancelDrag.value = 0;
|
||||
Haptics.send();
|
||||
return;
|
||||
}
|
||||
|
||||
final drag = (-offsetFromOrigin.dx / VoiceRecordController.cancelThreshold)
|
||||
.clamp(0.0, 1.0);
|
||||
_cancelDrag.value = drag;
|
||||
@@ -159,7 +231,10 @@ class VideoNoteController {
|
||||
}
|
||||
}
|
||||
|
||||
void handleEnd() => stop(cancel: false);
|
||||
void handleEnd() {
|
||||
if (_locked.value) return;
|
||||
stop(cancel: false);
|
||||
}
|
||||
|
||||
Future<void> stop({required bool cancel}) async {
|
||||
if (!_isRecording.value) {
|
||||
@@ -172,9 +247,14 @@ class VideoNoteController {
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
_isRecording.value = false;
|
||||
_cancelDrag.value = 0;
|
||||
_hideOverlay();
|
||||
_lockDrag.value = 0;
|
||||
_locked.value = false;
|
||||
if (_flashOn.value) {
|
||||
_flashOn.value = false;
|
||||
unawaited(_rec.setTorch(false));
|
||||
}
|
||||
|
||||
final path = await _rec.stop();
|
||||
final path = _stub ? await _stubClip() : await _rec.stop();
|
||||
|
||||
final shouldCancel =
|
||||
cancel || _cancelled || elapsed < VoiceRecordController.minMs;
|
||||
@@ -191,76 +271,8 @@ class VideoNoteController {
|
||||
await onRecorded(File(path), elapsed);
|
||||
}
|
||||
|
||||
void _showOverlay() {
|
||||
_overlay?.remove();
|
||||
_overlay = OverlayEntry(
|
||||
builder: (context) {
|
||||
final texId = _textureId.value;
|
||||
return Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: flipCamera,
|
||||
child: ClipOval(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
height: 260,
|
||||
child: texId != null
|
||||
? Texture(textureId: texId)
|
||||
: Container(color: Colors.black),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: _elapsedMs,
|
||||
builder: (context, ms, _) => Text(
|
||||
formatElapsed(ms),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontFeatures: [ui.FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: _cancelDrag,
|
||||
builder: (context, drag, _) => Opacity(
|
||||
opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0),
|
||||
child: const Text(
|
||||
'‹ влево — отмена',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'тап по кружку — сменить камеру',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
final overlay = Overlay.of(contextOf(), rootOverlay: true);
|
||||
overlay.insert(_overlay!);
|
||||
}
|
||||
|
||||
void _hideOverlay() {
|
||||
_overlay?.remove();
|
||||
_overlay = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_overlay?.remove();
|
||||
_rec.dispose();
|
||||
_textureId.dispose();
|
||||
_videoNoteMode.dispose();
|
||||
@@ -268,5 +280,246 @@ class VideoNoteController {
|
||||
_isRecording.dispose();
|
||||
_elapsedMs.dispose();
|
||||
_cancelDrag.dispose();
|
||||
_locked.dispose();
|
||||
_lockDrag.dispose();
|
||||
_flashOn.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class VideoNoteRecordingLayer extends StatefulWidget {
|
||||
const VideoNoteRecordingLayer({super.key, required this.controller});
|
||||
|
||||
final VideoNoteController controller;
|
||||
|
||||
@override
|
||||
State<VideoNoteRecordingLayer> createState() =>
|
||||
_VideoNoteRecordingLayerState();
|
||||
}
|
||||
|
||||
class _VideoNoteRecordingLayerState extends State<VideoNoteRecordingLayer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const double _circle = 260;
|
||||
static const double _maxBlur = 18;
|
||||
|
||||
late final AnimationController _reveal = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.isRecording.addListener(_onRecordingChanged);
|
||||
}
|
||||
|
||||
void _onRecordingChanged() {
|
||||
if (widget.controller.isRecording.value) {
|
||||
_reveal.forward();
|
||||
} else {
|
||||
_reveal.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.isRecording.removeListener(_onRecordingChanged);
|
||||
_reveal.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final controller = widget.controller;
|
||||
return Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _reveal,
|
||||
builder: (context, child) {
|
||||
final t = Curves.easeOut.transform(_reveal.value);
|
||||
if (t <= 0.001) return const SizedBox.shrink();
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: _maxBlur * t,
|
||||
sigmaY: _maxBlur * t,
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.35 * t),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned.fill(
|
||||
child: AbsorbPointer(child: SizedBox.expand()),
|
||||
),
|
||||
Opacity(opacity: t, child: child),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: controller.elapsedMs,
|
||||
builder: (context, ms, child) => CustomPaint(
|
||||
foregroundPainter: _NoteProgressPainter(
|
||||
progress: (ms / VideoNoteController.maxMs).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: _circle,
|
||||
height: _circle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: ClipOval(
|
||||
child: ValueListenableBuilder<int?>(
|
||||
valueListenable: controller.textureId,
|
||||
builder: (context, texId, _) => texId == null
|
||||
? _StubPreview(controller: controller)
|
||||
: Texture(textureId: texId),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom:
|
||||
MediaQuery.paddingOf(context).bottom +
|
||||
widget.controller.bottomInset() +
|
||||
12,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _CameraControls(controller: controller, cs: cs),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StubPreview extends StatelessWidget {
|
||||
const _StubPreview({required this.controller});
|
||||
|
||||
final VideoNoteController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: controller.elapsedMs,
|
||||
builder: (context, ms, _) {
|
||||
final hue = (ms / 40) % 360;
|
||||
return ColoredBox(
|
||||
color: HSVColor.fromAHSV(1, hue, 0.45, 0.35).toColor(),
|
||||
child: const Center(
|
||||
child: Icon(Symbols.videocam, size: 64, color: Colors.white54),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CameraControls extends StatelessWidget {
|
||||
const _CameraControls({required this.controller, required this.cs});
|
||||
|
||||
final VideoNoteController controller;
|
||||
final ColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GlossyPill(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
depth: 10,
|
||||
elevated: true,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (controller.cameraControlsAvailable)
|
||||
_ControlButton(
|
||||
icon: Symbols.flip_camera_ios,
|
||||
color: cs.onSurface,
|
||||
onTap: controller.flipCamera,
|
||||
),
|
||||
if (controller.flashAvailable)
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: controller.flashOn,
|
||||
builder: (context, on, _) => _ControlButton(
|
||||
icon: on ? Symbols.flash_on : Symbols.flash_off,
|
||||
color: on ? cs.primary : cs.onSurface,
|
||||
onTap: controller.toggleFlash,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ControlButton extends StatelessWidget {
|
||||
const _ControlButton({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkResponse(
|
||||
onTap: onTap,
|
||||
radius: 26,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(icon, size: 24, color: color, fill: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoteProgressPainter extends CustomPainter {
|
||||
const _NoteProgressPainter({required this.progress, required this.color});
|
||||
|
||||
final double progress;
|
||||
final Color color;
|
||||
|
||||
static const double _stroke = 4;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (progress <= 0) return;
|
||||
final circle = (Offset.zero & size).deflate(_stroke / 2);
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = _stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color;
|
||||
canvas.drawArc(circle, -math.pi / 2, math.pi * 2 * progress, false, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_NoteProgressPainter old) => old.progress != progress;
|
||||
}
|
||||
|
||||
@@ -332,24 +332,34 @@ class ComposerInputBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: voiceRec.isRecording,
|
||||
builder: (context, recording, _) => IgnorePointer(
|
||||
ignoring: !recording,
|
||||
child: AnimatedSlide(
|
||||
offset: recording
|
||||
? Offset.zero
|
||||
: const Offset(0.06, 0),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: AnimatedOpacity(
|
||||
opacity: recording ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
child: _voiceRecordingIndicator(cs),
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
voiceRec.isRecording,
|
||||
note.isRecording,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final video = note.isRecording.value;
|
||||
final recording =
|
||||
video || voiceRec.isRecording.value;
|
||||
return IgnorePointer(
|
||||
ignoring: !recording,
|
||||
child: AnimatedSlide(
|
||||
offset: recording
|
||||
? Offset.zero
|
||||
: const Offset(0.06, 0),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: AnimatedOpacity(
|
||||
opacity: recording ? 1 : 0,
|
||||
duration: const Duration(
|
||||
milliseconds: 180,
|
||||
),
|
||||
curve: Curves.easeOut,
|
||||
child: _recordingIndicator(cs, video),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -390,13 +400,26 @@ class ComposerInputBar extends StatelessWidget {
|
||||
valueListenable: hasText,
|
||||
builder: (context, hasText, _) => ValueListenableBuilder<bool>(
|
||||
valueListenable: voiceRec.locked,
|
||||
builder: (context, locked, _) =>
|
||||
builder: (context, voiceLocked, _) =>
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: voiceRec.isRecording,
|
||||
builder: (context, recording, _) =>
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: note.videoNoteMode,
|
||||
builder: (context, videoMode, _) {
|
||||
builder: (context, voiceRecording, _) =>
|
||||
AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
note.videoNoteMode,
|
||||
note.isRecording,
|
||||
note.locked,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final videoMode =
|
||||
note.videoNoteMode.value;
|
||||
final noteRecording =
|
||||
note.isRecording.value;
|
||||
final recording =
|
||||
voiceRecording || noteRecording;
|
||||
final locked = noteRecording
|
||||
? note.locked.value
|
||||
: voiceLocked;
|
||||
final sendMode =
|
||||
hasText ||
|
||||
hasForward ||
|
||||
@@ -416,9 +439,11 @@ class ComposerInputBar extends StatelessWidget {
|
||||
forceSend)
|
||||
? onSendText
|
||||
: locked
|
||||
? () => voiceRec.stop(
|
||||
cancel: false,
|
||||
)
|
||||
? () => noteRecording
|
||||
? note.stop(cancel: false)
|
||||
: voiceRec.stop(
|
||||
cancel: false,
|
||||
)
|
||||
: null,
|
||||
onLongPress:
|
||||
(hasText &&
|
||||
@@ -783,7 +808,14 @@ class ComposerInputBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_voiceLockChip(cs, a),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: note.isRecording,
|
||||
builder: (context, video, _) => _lockChip(
|
||||
cs,
|
||||
a,
|
||||
video ? note.lockDrag : voiceRec.lockDrag,
|
||||
),
|
||||
),
|
||||
Transform.scale(
|
||||
scale: 1.0 + a * 0.14 + a * v * 0.24,
|
||||
child: pill,
|
||||
@@ -797,11 +829,15 @@ class ComposerInputBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _voiceLockChip(ColorScheme cs, double reveal) {
|
||||
Widget _lockChip(
|
||||
ColorScheme cs,
|
||||
double reveal,
|
||||
ValueListenable<double> lockDrag,
|
||||
) {
|
||||
return Positioned(
|
||||
bottom: 62,
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: voiceRec.lockDrag,
|
||||
valueListenable: lockDrag,
|
||||
builder: (context, lock, _) => Opacity(
|
||||
opacity: (reveal * (0.5 + lock * 0.5)).clamp(0.0, 1.0),
|
||||
child: Transform.translate(
|
||||
@@ -843,7 +879,7 @@ class ComposerInputBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _voiceRecordingIndicator(ColorScheme cs) {
|
||||
Widget _recordingIndicator(ColorScheme cs, bool video) {
|
||||
return Container(
|
||||
color: Color.alphaBlend(
|
||||
cs.surfaceContainerHighest.withValues(alpha: 0.92),
|
||||
@@ -852,20 +888,23 @@ class ComposerInputBar extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: voiceRec.amplitude,
|
||||
builder: (context, amp, child) => TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: amp),
|
||||
duration: const Duration(milliseconds: 120),
|
||||
builder: (context, v, child) =>
|
||||
Transform.scale(scale: 1.0 + v * 0.7, child: child),
|
||||
child: child,
|
||||
if (video)
|
||||
_RecordingDot(color: cs.error)
|
||||
else
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: voiceRec.amplitude,
|
||||
builder: (context, amp, child) => TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: amp),
|
||||
duration: const Duration(milliseconds: 120),
|
||||
builder: (context, v, child) =>
|
||||
Transform.scale(scale: 1.0 + v * 0.7, child: child),
|
||||
child: child,
|
||||
),
|
||||
child: _RecordingDot(color: cs.error),
|
||||
),
|
||||
child: _RecordingDot(color: cs.error),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: voiceRec.elapsedMs,
|
||||
valueListenable: video ? note.elapsedMs : voiceRec.elapsedMs,
|
||||
builder: (context, ms, _) => Text(
|
||||
formatElapsed(ms),
|
||||
style: TextStyle(
|
||||
@@ -878,8 +917,22 @@ class ComposerInputBar extends StatelessWidget {
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: voiceRec.cancelDrag,
|
||||
valueListenable: video ? note.cancelDrag : voiceRec.cancelDrag,
|
||||
builder: (context, drag, _) {
|
||||
if (video) {
|
||||
return Opacity(
|
||||
opacity: (0.55 + drag * 0.45).clamp(0.0, 1.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'‹ Влево — отмена',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (drag > 0.01) {
|
||||
return Opacity(
|
||||
opacity: (0.45 + drag * 0.55).clamp(0.0, 1.0),
|
||||
@@ -921,16 +974,20 @@ class ComposerInputBar extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: voiceRec.locked,
|
||||
valueListenable: video ? note.locked : voiceRec.locked,
|
||||
builder: (context, locked, _) => locked
|
||||
? GestureDetector(
|
||||
onTap: () => voiceRec.stop(cancel: true),
|
||||
onTap: () => video
|
||||
? note.stop(cancel: true)
|
||||
: voiceRec.stop(cancel: true),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Icon(Symbols.delete, size: 22, color: cs.error),
|
||||
),
|
||||
)
|
||||
: video
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
'‹ влево — отмена',
|
||||
style: TextStyle(color: cs.mutedText, fontSize: 11),
|
||||
|
||||
@@ -142,6 +142,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
int _memberMarker = 0;
|
||||
bool _membersLoading = false;
|
||||
bool _membersEnd = false;
|
||||
static const int _memberRenderChunk = 24;
|
||||
int _memberRenderLimit = _memberRenderChunk;
|
||||
bool _memberFillScheduled = false;
|
||||
|
||||
int _mediaChatId = 0;
|
||||
String? _anchorMsgId;
|
||||
@@ -455,7 +458,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
added++;
|
||||
}
|
||||
}
|
||||
if (added > 0) _rebuildMembers();
|
||||
if (added > 0) {
|
||||
_rebuildMembers();
|
||||
_scheduleMemberFillCheck();
|
||||
}
|
||||
if (fresh.isNotEmpty && AppStories.current.value) {
|
||||
unawaited(storiesModule.loadOwnersPreviews(fresh));
|
||||
}
|
||||
@@ -472,17 +478,38 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
if (!initial) setState(() {});
|
||||
}
|
||||
|
||||
bool _revealMoreMembers() {
|
||||
if (_memberRenderLimit >= _members.length) return false;
|
||||
setState(() => _memberRenderLimit += _memberRenderChunk);
|
||||
_scheduleMemberFillCheck();
|
||||
return true;
|
||||
}
|
||||
|
||||
void _scheduleMemberFillCheck() {
|
||||
if (_memberFillScheduled) return;
|
||||
_memberFillScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_memberFillScheduled = false;
|
||||
if (!mounted) return;
|
||||
if (_memberRenderLimit >= _members.length) return;
|
||||
final controller = _bodyScrollController;
|
||||
if (controller == null || !controller.hasClients) return;
|
||||
if (controller.position.maxScrollExtent > 0) return;
|
||||
_revealMoreMembers();
|
||||
});
|
||||
}
|
||||
|
||||
void _onBodyScroll() {
|
||||
if (!mounted || widget.chatType != 'CHAT') return;
|
||||
if (_membersLoading || _membersEnd) return;
|
||||
if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers)
|
||||
return;
|
||||
final controller = _bodyScrollController;
|
||||
if (controller == null || !controller.hasClients) return;
|
||||
final pos = controller.position;
|
||||
if (pos.pixels >= pos.maxScrollExtent - 400) {
|
||||
_fetchMembersPage();
|
||||
}
|
||||
if (pos.pixels < pos.maxScrollExtent - 400) return;
|
||||
if (_revealMoreMembers()) return;
|
||||
if (_membersLoading || _membersEnd) return;
|
||||
_fetchMembersPage();
|
||||
}
|
||||
|
||||
String? get _inviteLink {
|
||||
@@ -522,6 +549,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
_memberMarker = 0;
|
||||
_membersEnd = false;
|
||||
_membersLoading = false;
|
||||
_memberRenderLimit = _memberRenderChunk;
|
||||
_rebuildMembers();
|
||||
if (mounted) setState(() {});
|
||||
await _fetchMembersPage(initial: true);
|
||||
@@ -2148,6 +2176,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Widget _buildMembersTabContent(ColorScheme cs) {
|
||||
final hasHidden = _members.length > _memberRenderLimit;
|
||||
final shown = hasHidden ? _members.take(_memberRenderLimit) : _members;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
@@ -2170,8 +2200,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
() => _openInviteLink(_inviteLink!),
|
||||
),
|
||||
],
|
||||
..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]),
|
||||
if (_membersLoading || !_membersEnd) ...[
|
||||
...shown.expand((m) => [_listDivider(cs), _memberTile(cs, m)]),
|
||||
if (hasHidden || _membersLoading || !_membersEnd) ...[
|
||||
_listDivider(cs),
|
||||
_membersFooter(cs),
|
||||
],
|
||||
@@ -2194,7 +2224,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
);
|
||||
}
|
||||
return InkWell(
|
||||
onTap: () => _fetchMembersPage(),
|
||||
onTap: () {
|
||||
if (!_revealMoreMembers()) _fetchMembersPage();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
|
||||
@@ -87,6 +87,7 @@ import '../stories/story_ring.dart';
|
||||
import '../../widgets/sending_clock_icon.dart';
|
||||
import '../stories/story_viewer_screen.dart';
|
||||
import '../downloads_screen.dart';
|
||||
import '../../widgets/media_playback_pill.dart';
|
||||
|
||||
class _StoriesScrollPhysics extends BouncingScrollPhysics {
|
||||
final bool Function() blockPositive;
|
||||
@@ -1751,6 +1752,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!widget.forwardMode)
|
||||
const MediaPlaybackPill(
|
||||
margin: EdgeInsets.fromLTRB(20, 6, 20, 2),
|
||||
),
|
||||
if (!widget.forwardMode) _buildInformerBanner(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -116,6 +116,8 @@ import 'chat_encryption_screen.dart';
|
||||
import 'chat_wallpaper_preview_screen.dart';
|
||||
import 'chat/retain_offset_physics.dart';
|
||||
import 'profile_action_sheets.dart';
|
||||
import '../../../core/media/media_playback.dart';
|
||||
import '../../widgets/media_playback_pill.dart';
|
||||
|
||||
class _DateSeparatorItem {
|
||||
final DateTime date;
|
||||
@@ -308,6 +310,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
int _readMarkTime = 0;
|
||||
Timer? _readMarkTimer;
|
||||
final GlobalKey _listKey = GlobalKey();
|
||||
final GlobalKey _unreadSeparatorKey = GlobalKey();
|
||||
final Object _profileHeroTag = UniqueKey();
|
||||
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
||||
bool _isLoading = true;
|
||||
@@ -349,6 +352,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
isMounted: () => mounted,
|
||||
onRecorded: _sendVideoNote,
|
||||
formatElapsed: formatVoiceElapsed,
|
||||
bottomInset: () => _composerHeight.value,
|
||||
);
|
||||
|
||||
StreamSubscription<UploadJobEvent>? _uploadEventSub;
|
||||
@@ -677,6 +681,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_scrollController.addListener(_scheduleReadMarker);
|
||||
_scrollController.addListener(_exitTextSelectionOnScroll);
|
||||
_scrollController.addListener(_updateScrollDownVisible);
|
||||
MediaPlayback.instance.enterChat(widget.chatId);
|
||||
AppVisualStyle.current.addListener(_onVisualStyleChanged);
|
||||
AppChatChrome.current.addListener(_onVisualStyleChanged);
|
||||
AppComposerStyle.current.addListener(_onVisualStyleChanged);
|
||||
@@ -1049,14 +1054,16 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (listBox is! RenderBox || listBox.size.height <= 0) {
|
||||
return _unreadAnchorFallbackAlignment;
|
||||
}
|
||||
final separator =
|
||||
_unreadSeparatorKey.currentContext?.size?.height ??
|
||||
_unreadSeparatorHeight;
|
||||
final glossy = AppVisualStyle.current.value.glossyChrome;
|
||||
final chromeBottom = _effectiveChrome == ChatChromeStyle.color
|
||||
? 0.0
|
||||
: MediaQuery.paddingOf(context).top +
|
||||
(glossy ? _glossyHeaderHeight : kToolbarHeight) +
|
||||
_pinnedBannerHeight.value;
|
||||
final desiredTop =
|
||||
chromeBottom + _unreadSeparatorHeight + _unreadSeparatorInset;
|
||||
final desiredTop = chromeBottom + separator + _unreadSeparatorInset;
|
||||
return (desiredTop / listBox.size.height).clamp(0.0, 0.5);
|
||||
}
|
||||
|
||||
@@ -1292,6 +1299,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final atBottom = candidate.id == _messages.last.id;
|
||||
|
||||
if (_unreadAnchorTime != null &&
|
||||
_userDidScroll &&
|
||||
_unreadSeparatorScrolledPast(
|
||||
atBottom,
|
||||
topIndex,
|
||||
@@ -2049,6 +2057,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_scrollController.removeListener(_updateScrollDownVisible);
|
||||
_readMarkTimer?.cancel();
|
||||
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
|
||||
MediaPlayback.instance.leaveChat(widget.chatId);
|
||||
AppChatChrome.current.removeListener(_onVisualStyleChanged);
|
||||
AppComposerStyle.current.removeListener(_onVisualStyleChanged);
|
||||
AppComposerBackground.current.removeListener(_onVisualStyleChanged);
|
||||
@@ -4893,9 +4902,15 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (item is! _MessageItem) continue;
|
||||
final box = _messageKeys[item.message.id]?.currentContext
|
||||
?.findRenderObject();
|
||||
if (box is! RenderBox || !box.attached) continue;
|
||||
if (box is! RenderBox || !box.attached) {
|
||||
if (oldest != -1) break;
|
||||
continue;
|
||||
}
|
||||
final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy;
|
||||
if (top + box.size.height <= 0 || top >= viewportBottom) continue;
|
||||
if (top + box.size.height <= 0 || top >= viewportBottom) {
|
||||
if (oldest != -1) break;
|
||||
continue;
|
||||
}
|
||||
if (oldest == -1) oldest = i;
|
||||
newest = i;
|
||||
}
|
||||
@@ -5201,6 +5216,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final accent = cs.primary;
|
||||
return Padding(
|
||||
key: _unreadSeparatorKey,
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -5311,13 +5327,43 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildPinnedBanner({required bool floating}) {
|
||||
Widget _buildPinnedAndPill() {
|
||||
return ValueListenableBuilder<PlaybackKind?>(
|
||||
valueListenable: MediaPlayback.instance.primary,
|
||||
builder: (context, kind, _) {
|
||||
final merged = kind != null;
|
||||
final banner = _buildPinnedBanner(
|
||||
floating: true,
|
||||
borderRadius: merged
|
||||
? const BorderRadius.vertical(top: Radius.circular(16))
|
||||
: null,
|
||||
);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
?banner,
|
||||
MediaPlaybackPill(
|
||||
borderRadius: banner == null
|
||||
? BorderRadius.circular(16)
|
||||
: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildPinnedBanner({
|
||||
required bool floating,
|
||||
BorderRadius? borderRadius,
|
||||
}) {
|
||||
final pinned = chat;
|
||||
if (pinned == null || !pinned.hasPinnedMessage) return null;
|
||||
return _PinnedMessageBanner(
|
||||
text: pinned.pinnedMsgText,
|
||||
isPreview: pinned.pinnedMsgIsPreview,
|
||||
floating: floating,
|
||||
borderRadius: borderRadius,
|
||||
frosted: _effectiveChrome == ChatChromeStyle.transparent,
|
||||
liquid: _liquidChrome,
|
||||
backdropKey: _pillBackdrop,
|
||||
@@ -5363,6 +5409,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
VideoNoteRecordingLayer(controller: _note),
|
||||
if (frosted)
|
||||
Positioned(left: 0, right: 0, bottom: 0, child: composer),
|
||||
SearchOverlay(
|
||||
@@ -5388,21 +5435,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_pinnedBannerLift;
|
||||
}
|
||||
|
||||
void _resetPinnedBannerHeight() {
|
||||
if (_pinnedBannerHeight.value == 0) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && chat?.hasPinnedMessage != true) {
|
||||
_pinnedBannerHeight.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildUnderlapBody() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final vignette = _effectiveChrome == ChatChromeStyle.none;
|
||||
final bannerTop = _pinnedBannerTop();
|
||||
final banner = _buildPinnedBanner(floating: true);
|
||||
if (banner == null) _resetPinnedBannerHeight();
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -5434,16 +5470,15 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
),
|
||||
],
|
||||
if (banner != null)
|
||||
Positioned(
|
||||
top: bannerTop,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: _MeasureSize(
|
||||
onHeight: (value) => _pinnedBannerHeight.value = value,
|
||||
child: banner,
|
||||
),
|
||||
Positioned(
|
||||
top: bannerTop,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: _MeasureSize(
|
||||
onHeight: (value) => _pinnedBannerHeight.value = value,
|
||||
child: _buildPinnedAndPill(),
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: _composerHeight,
|
||||
builder: (context, height, _) => Positioned(
|
||||
@@ -5459,6 +5494,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
VideoNoteRecordingLayer(controller: _note),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -6940,6 +6976,7 @@ class _PinnedMessageBanner extends StatelessWidget {
|
||||
final bool floating;
|
||||
final bool frosted;
|
||||
final bool liquid;
|
||||
final BorderRadius? borderRadius;
|
||||
final BackdropKey? backdropKey;
|
||||
|
||||
const _PinnedMessageBanner({
|
||||
@@ -6948,11 +6985,14 @@ class _PinnedMessageBanner extends StatelessWidget {
|
||||
required this.onTap,
|
||||
this.onUnpin,
|
||||
this.floating = false,
|
||||
this.borderRadius,
|
||||
this.frosted = false,
|
||||
this.liquid = false,
|
||||
this.backdropKey,
|
||||
});
|
||||
|
||||
BorderRadius get _radius => borderRadius ?? BorderRadius.circular(16);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
@@ -6962,7 +7002,7 @@ class _PinnedMessageBanner extends StatelessWidget {
|
||||
: floating
|
||||
? cs.surfaceContainerHigh.withValues(alpha: 0.92)
|
||||
: cs.surfaceContainerHigh,
|
||||
borderRadius: floating ? BorderRadius.circular(16) : null,
|
||||
borderRadius: floating ? _radius : null,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
|
||||
@@ -45,6 +45,7 @@ import 'komet_settings_screen.dart';
|
||||
import 'notifications_screen.dart';
|
||||
import 'security_screen.dart';
|
||||
import 'spoof_screen.dart';
|
||||
import '../../widgets/media_playback_pill.dart';
|
||||
|
||||
class SettingsTab extends StatefulWidget {
|
||||
const SettingsTab({super.key});
|
||||
@@ -381,6 +382,11 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
|
||||
_buildHeader(ctx, cs, fullName, phone, t),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: MediaPlaybackPill(
|
||||
margin: EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
|
||||
Reference in New Issue
Block a user