mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-25 05:04:07 +00:00
commit
6a106a7fa2
26 changed files with 809 additions and 593 deletions
|
|
@ -1,5 +1,14 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.3.3
|
||||||
|
|
||||||
|
- Fix: Multiple UI issues
|
||||||
|
- Fix: Camera initialization issue
|
||||||
|
|
||||||
|
## 0.3.2
|
||||||
|
|
||||||
|
- Fix: Multiple smaller performance and UI issues
|
||||||
|
|
||||||
## 0.3.1
|
## 0.3.1
|
||||||
|
|
||||||
- New: Promotion of sharing contacts when contact is new to twonly
|
- New: Promotion of sharing contacts when contact is new to twonly
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:camera/camera.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:mutex/mutex.dart';
|
import 'package:mutex/mutex.dart';
|
||||||
|
|
@ -32,7 +33,7 @@ import 'package:twonly/src/utils/startup_guard.dart';
|
||||||
|
|
||||||
final _initMutex = Mutex();
|
final _initMutex = Mutex();
|
||||||
|
|
||||||
/// This function is used to initialized the absolute minimum so it
|
/// This function is used to initialize the absolute minimum so it
|
||||||
/// can also be used by the backend without the UI was loaded.
|
/// can also be used by the backend without the UI was loaded.
|
||||||
Future<bool> twonlyMinimumInitialization() async {
|
Future<bool> twonlyMinimumInitialization() async {
|
||||||
Log.info('twonlyMinimumInitialization: called');
|
Log.info('twonlyMinimumInitialization: called');
|
||||||
|
|
@ -68,12 +69,21 @@ Future<bool> twonlyMinimumInitialization() async {
|
||||||
void main() async {
|
void main() async {
|
||||||
final binding = SentryWidgetsFlutterBinding.ensureInitialized();
|
final binding = SentryWidgetsFlutterBinding.ensureInitialized();
|
||||||
await AppEnvironment.init();
|
await AppEnvironment.init();
|
||||||
|
|
||||||
|
// Preload available cameras in the background to speed up camera tab startup
|
||||||
|
unawaited(
|
||||||
|
availableCameras().then((cameras) {
|
||||||
|
AppEnvironment.cameras = cameras;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
|
|
||||||
unawaited(StartupGuard.markAppStartup());
|
unawaited(StartupGuard.markAppStartup());
|
||||||
|
|
||||||
var storageError = await twonlyMinimumInitialization();
|
var storageError = await twonlyMinimumInitialization();
|
||||||
await FcmNotificationService.initStartup();
|
await FcmNotificationService.initStartup();
|
||||||
|
await setupPushNotification();
|
||||||
|
|
||||||
var userExists = false;
|
var userExists = false;
|
||||||
|
|
||||||
|
|
@ -163,7 +173,6 @@ Future<void> postStartupTasks() async {
|
||||||
unawaited(MediaFileService.purgeTempFolder());
|
unawaited(MediaFileService.purgeTempFolder());
|
||||||
|
|
||||||
// 2. Service initializations
|
// 2. Service initializations
|
||||||
unawaited(setupPushNotification());
|
|
||||||
unawaited(finishStartedPreprocessing());
|
unawaited(finishStartedPreprocessing());
|
||||||
unawaited(createPushAvatars());
|
unawaited(createPushAvatars());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -314,11 +314,15 @@ class MediaFileService {
|
||||||
await tempPath.copy(storedPath.path);
|
await tempPath.copy(storedPath.path);
|
||||||
if (userService.currentUser.storeMediaFilesInGallery) {
|
if (userService.currentUser.storeMediaFilesInGallery) {
|
||||||
if (mediaFile.type == MediaType.video) {
|
if (mediaFile.type == MediaType.video) {
|
||||||
await saveVideoToGallery(storedPath.path);
|
await saveVideoToGallery(
|
||||||
|
storedPath.path,
|
||||||
|
name: mediaFile.mediaId,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await saveImageToGallery(
|
await saveImageToGallery(
|
||||||
storedPath.readAsBytesSync(),
|
storedPath.readAsBytesSync(),
|
||||||
createdAt: mediaFile.createdAt,
|
createdAt: mediaFile.createdAt,
|
||||||
|
name: mediaFile.mediaId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ Future<void> showLocalPushNotification(
|
||||||
ticker: 'You got a new message.',
|
ticker: 'You got a new message.',
|
||||||
largeIcon: styleInformation,
|
largeIcon: styleInformation,
|
||||||
icon: 'ic_launcher_foreground',
|
icon: 'ic_launcher_foreground',
|
||||||
|
groupKey: 'com.twonly.messages',
|
||||||
);
|
);
|
||||||
|
|
||||||
const darwinNotificationDetails = DarwinNotificationDetails();
|
const darwinNotificationDetails = DarwinNotificationDetails();
|
||||||
|
|
@ -190,43 +191,29 @@ Future<void> showLocalPushNotification(
|
||||||
notificationDetails,
|
notificationDetails,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> showLocalPushNotificationWithoutUserId(
|
if (Platform.isAndroid) {
|
||||||
PushNotification pushNotification,
|
final summaryAndroidDetails = AndroidNotificationDetails(
|
||||||
) async {
|
'0',
|
||||||
final lang = getLocalizations();
|
lang.notificationCategoryMessageTitle,
|
||||||
|
channelDescription: lang.notificationCategoryMessageDesc,
|
||||||
var title = lang.notificationTitleUnknown;
|
importance: Importance.max,
|
||||||
var body = lang.notificationBodyUnknown;
|
priority: Priority.max,
|
||||||
|
groupKey: 'com.twonly.messages',
|
||||||
if (pushNotification.kind == PushKind.CONTACT_REQUEST) {
|
setAsGroupSummary: true,
|
||||||
title = lang.you;
|
icon: 'ic_launcher_foreground',
|
||||||
body = lang.notificationContactRequestUnknownUser;
|
);
|
||||||
|
final summaryNotificationDetails = NotificationDetails(
|
||||||
|
android: summaryAndroidDetails,
|
||||||
|
);
|
||||||
|
await flutterLocalNotificationsPlugin.show(
|
||||||
|
0,
|
||||||
|
lang.notificationCategoryMessageTitle,
|
||||||
|
'',
|
||||||
|
summaryNotificationDetails,
|
||||||
|
payload: Routes.chats,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final androidNotificationDetails = AndroidNotificationDetails(
|
|
||||||
'0',
|
|
||||||
lang.notificationCategoryMessageTitle,
|
|
||||||
channelDescription: lang.notificationCategoryMessageDesc,
|
|
||||||
importance: Importance.max,
|
|
||||||
priority: Priority.max,
|
|
||||||
ticker: 'You got a new message.',
|
|
||||||
);
|
|
||||||
|
|
||||||
const darwinNotificationDetails = DarwinNotificationDetails();
|
|
||||||
final notificationDetails = NotificationDetails(
|
|
||||||
android: androidNotificationDetails,
|
|
||||||
iOS: darwinNotificationDetails,
|
|
||||||
);
|
|
||||||
|
|
||||||
await flutterLocalNotificationsPlugin.show(
|
|
||||||
2,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
notificationDetails,
|
|
||||||
payload: pushNotification.kind.name,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> getAvatarIcon(int contactId) async {
|
Future<String?> getAvatarIcon(int contactId) async {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:gal/gal.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:twonly/src/localization/generated/app_localizations.dart';
|
import 'package:twonly/src/localization/generated/app_localizations.dart';
|
||||||
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
|
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
|
||||||
|
|
@ -36,6 +37,7 @@ extension ShortCutsExtension on BuildContext {
|
||||||
Future<String?> saveImageToGallery(
|
Future<String?> saveImageToGallery(
|
||||||
Uint8List imageBytes, {
|
Uint8List imageBytes, {
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
String? name,
|
||||||
}) async {
|
}) async {
|
||||||
var bytesToProcess = imageBytes;
|
var bytesToProcess = imageBytes;
|
||||||
|
|
||||||
|
|
@ -75,7 +77,7 @@ Future<String?> saveImageToGallery(
|
||||||
await Gal.requestAccess(toAlbum: true);
|
await Gal.requestAccess(toAlbum: true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await Gal.putImageBytes(jpgImages, album: 'twonly');
|
await Gal.putImageBytes(jpgImages, album: 'twonly', name: name ?? 'image');
|
||||||
return null;
|
return null;
|
||||||
} on GalException catch (e) {
|
} on GalException catch (e) {
|
||||||
Log.error(e);
|
Log.error(e);
|
||||||
|
|
@ -83,17 +85,45 @@ Future<String?> saveImageToGallery(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> saveVideoToGallery(String videoPath) async {
|
Future<String?> saveVideoToGallery(
|
||||||
|
String videoPath, {
|
||||||
|
String? name,
|
||||||
|
}) async {
|
||||||
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
await Gal.requestAccess(toAlbum: true);
|
await Gal.requestAccess(toAlbum: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pathToSave = videoPath;
|
||||||
|
File? tempFile;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Gal.putVideo(videoPath, album: 'twonly');
|
if (name != null) {
|
||||||
|
final file = File(videoPath);
|
||||||
|
final extension = file.path.split('.').last;
|
||||||
|
final tempDir = Directory.systemTemp;
|
||||||
|
tempFile = File(join(tempDir.path, '$name.$extension'));
|
||||||
|
if (tempFile.existsSync()) {
|
||||||
|
try {
|
||||||
|
tempFile.deleteSync();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
file.copySync(tempFile.path);
|
||||||
|
pathToSave = tempFile.path;
|
||||||
|
}
|
||||||
|
await Gal.putVideo(pathToSave, album: 'twonly');
|
||||||
return null;
|
return null;
|
||||||
} on GalException catch (e) {
|
} on GalException catch (e) {
|
||||||
Log.error(e);
|
Log.error(e);
|
||||||
return e.type.message;
|
return e.type.message;
|
||||||
|
} finally {
|
||||||
|
if (tempFile != null && tempFile.existsSync()) {
|
||||||
|
try {
|
||||||
|
tempFile.deleteSync();
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Failed to delete temp video file: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ enum MyButtonVariant {
|
||||||
primaryMiddle,
|
primaryMiddle,
|
||||||
primaryDense,
|
primaryDense,
|
||||||
secondaryDense,
|
secondaryDense,
|
||||||
|
secondaryMiddle,
|
||||||
error,
|
error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,6 +213,25 @@ class _MyButtonState extends State<MyButton>
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
case MyButtonVariant.secondaryMiddle:
|
||||||
|
buttonStyle = FilledButton.styleFrom(
|
||||||
|
backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200],
|
||||||
|
foregroundColor: isDark ? Colors.white : Colors.black87,
|
||||||
|
disabledBackgroundColor: disabledBgColor,
|
||||||
|
disabledForegroundColor: disabledFgColor,
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
case MyButtonVariant.error:
|
case MyButtonVariant.error:
|
||||||
buttonStyle = FilledButton.styleFrom(
|
buttonStyle = FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ class _CameraPreviewControllerViewState
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return PermissionHandlerView(
|
return PermissionHandlerView(
|
||||||
|
triggerPermissionRequest: widget.isVisible,
|
||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
AppState.hasCameraPermissions = true;
|
AppState.hasCameraPermissions = true;
|
||||||
|
|
|
||||||
|
|
@ -134,14 +134,25 @@ class MainCameraController {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> selectCamera(int sCameraId, bool init) async {
|
Future<void> selectCamera(int sCameraId, bool init) async {
|
||||||
|
if (initCameraStarted) return;
|
||||||
initCameraStarted = true;
|
initCameraStarted = true;
|
||||||
final sessionId = ++_cameraSessionId;
|
final sessionId = ++_cameraSessionId;
|
||||||
await _pendingDisposal;
|
|
||||||
if (sessionId != _cameraSessionId) return;
|
|
||||||
|
|
||||||
if (AppEnvironment.cameras.isEmpty) {
|
// Start checking microphone permission concurrently
|
||||||
AppEnvironment.cameras = await availableCameras();
|
final micPermissionFuture = Permission.microphone.isGranted;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _pendingDisposal;
|
||||||
if (sessionId != _cameraSessionId) return;
|
if (sessionId != _cameraSessionId) return;
|
||||||
|
|
||||||
|
if (AppEnvironment.cameras.isEmpty) {
|
||||||
|
AppEnvironment.cameras = await availableCameras();
|
||||||
|
if (sessionId != _cameraSessionId) return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Error querying available cameras: $e');
|
||||||
|
initCameraStarted = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var cameraId = sCameraId;
|
var cameraId = sCameraId;
|
||||||
|
|
@ -149,6 +160,7 @@ class MainCameraController {
|
||||||
Log.warn(
|
Log.warn(
|
||||||
'Trying to select a non existing camera $cameraId >= ${AppEnvironment.cameras.length}',
|
'Trying to select a non existing camera $cameraId >= ${AppEnvironment.cameras.length}',
|
||||||
);
|
);
|
||||||
|
initCameraStarted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,12 +171,21 @@ class MainCameraController {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (cameraId >= AppEnvironment.cameras.length) {
|
||||||
|
cameraId = sCameraId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedCameraDetails.isZoomAble = false;
|
selectedCameraDetails.isZoomAble = false;
|
||||||
|
|
||||||
if (cameraController == null) {
|
if (cameraController == null || !cameraController!.value.isInitialized) {
|
||||||
final hasMic = await Permission.microphone.isGranted;
|
final controllerToDispose = cameraController;
|
||||||
|
cameraController = null;
|
||||||
|
if (controllerToDispose != null) {
|
||||||
|
unawaited(controllerToDispose.dispose());
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasMic = await micPermissionFuture;
|
||||||
if (sessionId != _cameraSessionId) return;
|
if (sessionId != _cameraSessionId) return;
|
||||||
|
|
||||||
cameraController = CameraController(
|
cameraController = CameraController(
|
||||||
|
|
@ -178,58 +199,55 @@ class MainCameraController {
|
||||||
try {
|
try {
|
||||||
_initializeFuture = cameraController?.initialize();
|
_initializeFuture = cameraController?.initialize();
|
||||||
await _initializeFuture;
|
await _initializeFuture;
|
||||||
if (cameraController == null) return;
|
await cameraController!.startImageStream(_processCameraImage);
|
||||||
await cameraController?.startImageStream(_processCameraImage);
|
await cameraController!.setZoomLevel(selectedCameraDetails.scaleFactor);
|
||||||
if (cameraController == null) return;
|
|
||||||
await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor);
|
|
||||||
if (cameraController == null) return;
|
|
||||||
if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) {
|
if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) {
|
||||||
await cameraController?.setVideoStabilizationMode(
|
await cameraController!.setVideoStabilizationMode(
|
||||||
VideoStabilizationMode.level1,
|
VideoStabilizationMode.level1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
Log.error('Error initializing camera: $e');
|
||||||
|
final controllerToDispose = cameraController;
|
||||||
cameraController = null;
|
cameraController = null;
|
||||||
|
if (controllerToDispose != null) {
|
||||||
|
unawaited(controllerToDispose.dispose());
|
||||||
|
}
|
||||||
|
initCameraStarted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
if (!isVideoRecording) {
|
if (!isVideoRecording) {
|
||||||
await cameraController?.stopImageStream();
|
await cameraController!.stopImageStream();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
selectedCameraDetails.scaleFactor = 1;
|
||||||
Log.info(e);
|
|
||||||
}
|
|
||||||
if (cameraController == null) return;
|
|
||||||
selectedCameraDetails.scaleFactor = 1;
|
|
||||||
|
|
||||||
await cameraController?.setZoomLevel(1);
|
await cameraController!.setZoomLevel(1);
|
||||||
if (cameraController == null) return;
|
await cameraController!.setDescription(
|
||||||
await cameraController?.setDescription(AppEnvironment.cameras[cameraId]);
|
AppEnvironment.cameras[cameraId],
|
||||||
if (cameraController == null) return;
|
);
|
||||||
try {
|
|
||||||
if (!isVideoRecording) {
|
if (!isVideoRecording) {
|
||||||
await cameraController?.startImageStream(_processCameraImage);
|
await cameraController!.startImageStream(_processCameraImage);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.info(e);
|
Log.error('Error switching camera description: $e');
|
||||||
|
initCameraStarted = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (cameraController == null) return;
|
await cameraController!.lockCaptureOrientation(
|
||||||
await cameraController?.lockCaptureOrientation(
|
|
||||||
DeviceOrientation.portraitUp,
|
DeviceOrientation.portraitUp,
|
||||||
);
|
);
|
||||||
if (cameraController == null) return;
|
await cameraController!.setFlashMode(
|
||||||
await cameraController?.setFlashMode(
|
|
||||||
selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off,
|
selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off,
|
||||||
);
|
);
|
||||||
if (cameraController == null) return;
|
selectedCameraDetails.maxAvailableZoom = await cameraController!
|
||||||
selectedCameraDetails.maxAvailableZoom =
|
.getMaxZoomLevel();
|
||||||
await cameraController?.getMaxZoomLevel() ?? 1;
|
selectedCameraDetails.minAvailableZoom = await cameraController!
|
||||||
selectedCameraDetails.minAvailableZoom =
|
.getMinZoomLevel();
|
||||||
await cameraController?.getMinZoomLevel() ?? 1;
|
|
||||||
selectedCameraDetails
|
selectedCameraDetails
|
||||||
..isZoomAble =
|
..isZoomAble =
|
||||||
selectedCameraDetails.maxAvailableZoom !=
|
selectedCameraDetails.maxAvailableZoom !=
|
||||||
|
|
@ -242,10 +260,16 @@ class MainCameraController {
|
||||||
isSelectingFaceFilters = false;
|
isSelectingFaceFilters = false;
|
||||||
setFilter(FaceFilterType.none);
|
setFilter(FaceFilterType.none);
|
||||||
zoomButtonKey = GlobalKey();
|
zoomButtonKey = GlobalKey();
|
||||||
|
initCameraStarted = false;
|
||||||
setState?.call();
|
setState?.call();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error(e);
|
Log.error('Error post-initializing camera: $e');
|
||||||
|
final controllerToDispose = cameraController;
|
||||||
cameraController = null;
|
cameraController = null;
|
||||||
|
if (controllerToDispose != null) {
|
||||||
|
unawaited(controllerToDispose.dispose());
|
||||||
|
}
|
||||||
|
initCameraStarted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -312,7 +336,6 @@ class MainCameraController {
|
||||||
}
|
}
|
||||||
final inputImage = _inputImageFromCameraImage(image);
|
final inputImage = _inputImageFromCameraImage(image);
|
||||||
if (inputImage == null) return;
|
if (inputImage == null) return;
|
||||||
_processBarcode(inputImage);
|
|
||||||
// check if front camera is selected
|
// check if front camera is selected
|
||||||
if (cameraController?.description.lensDirection ==
|
if (cameraController?.description.lensDirection ==
|
||||||
CameraLensDirection.front) {
|
CameraLensDirection.front) {
|
||||||
|
|
@ -462,7 +485,10 @@ class MainCameraController {
|
||||||
scannedUrl = link;
|
scannedUrl = link;
|
||||||
if (sharedLinkForPreview == null) {
|
if (sharedLinkForPreview == null) {
|
||||||
timeSharedLinkWasSetWithQr = clock.now();
|
timeSharedLinkWasSetWithQr = clock.now();
|
||||||
setSharedLinkForPreview(Uri.parse(scannedUrl!), generatePreview: false);
|
setSharedLinkForPreview(
|
||||||
|
Uri.parse(scannedUrl!),
|
||||||
|
generatePreview: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,18 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
||||||
class PermissionHandlerView extends StatefulWidget {
|
class PermissionHandlerView extends StatefulWidget {
|
||||||
const PermissionHandlerView({required this.onSuccess, super.key});
|
const PermissionHandlerView({
|
||||||
|
required this.onSuccess,
|
||||||
|
this.triggerPermissionRequest = true,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final Function onSuccess;
|
final Function onSuccess;
|
||||||
|
final bool triggerPermissionRequest;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
PermissionHandlerViewState createState() => PermissionHandlerViewState();
|
PermissionHandlerViewState createState() => PermissionHandlerViewState();
|
||||||
|
|
@ -17,10 +23,6 @@ Future<bool> checkPermissions() async {
|
||||||
if (!await Permission.camera.isGranted) {
|
if (!await Permission.camera.isGranted) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!await Permission.microphone.isGranted) {
|
|
||||||
// microphone is only needed when
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +38,32 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
|
||||||
_timer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
_timer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||||
await _checkAndTriggerSuccess();
|
await _checkAndTriggerSuccess();
|
||||||
});
|
});
|
||||||
|
if (widget.triggerPermissionRequest) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
await _requestPermissions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(PermissionHandlerView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.triggerPermissionRequest && !oldWidget.triggerPermissionRequest) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
await _requestPermissions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestPermissions() async {
|
||||||
|
try {
|
||||||
|
await permissionServices();
|
||||||
|
if (await checkPermissions()) {
|
||||||
|
_handleSuccess();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -54,21 +82,32 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
|
||||||
|
|
||||||
Future<void> _checkAndTriggerSuccess() async {
|
Future<void> _checkAndTriggerSuccess() async {
|
||||||
if (_isSuccessTriggered) return;
|
if (_isSuccessTriggered) return;
|
||||||
|
final route = ModalRoute.of(context);
|
||||||
|
if (route != null && !route.isCurrent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (await checkPermissions()) {
|
if (await checkPermissions()) {
|
||||||
_isSuccessTriggered = true;
|
_handleSuccess();
|
||||||
_timer?.cancel();
|
|
||||||
// ignore: avoid_dynamic_calls
|
|
||||||
widget.onSuccess();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error(e);
|
Log.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleSuccess() {
|
||||||
|
if (_isSuccessTriggered) return;
|
||||||
|
_isSuccessTriggered = true;
|
||||||
|
_timer?.cancel();
|
||||||
|
unawaited(UserService.update((u) => u.requestedAudioPermission = true));
|
||||||
|
// ignore: avoid_dynamic_calls
|
||||||
|
widget.onSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<Permission, PermissionStatus>> permissionServices() async {
|
Future<Map<Permission, PermissionStatus>> permissionServices() async {
|
||||||
final statuses = await [
|
final statuses = await [
|
||||||
Permission.camera,
|
Permission.camera,
|
||||||
|
Permission.microphone,
|
||||||
Permission.notification,
|
Permission.notification,
|
||||||
].request();
|
].request();
|
||||||
|
|
||||||
|
|
@ -100,8 +139,7 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
|
||||||
try {
|
try {
|
||||||
await permissionServices();
|
await permissionServices();
|
||||||
if (await checkPermissions()) {
|
if (await checkPermissions()) {
|
||||||
// ignore: avoid_dynamic_calls
|
_handleSuccess();
|
||||||
widget.onSuccess();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error(e);
|
Log.error(e);
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isEnabled = !widget.isLoading && !_imageSaving;
|
final isEnabled = !widget.isLoading && !_imageSaving;
|
||||||
return MyButton(
|
return MyButton(
|
||||||
variant: MyButtonVariant.secondaryDense,
|
variant: MyButtonVariant.secondaryMiddle,
|
||||||
onPressed: isEnabled
|
onPressed: isEnabled
|
||||||
? () async {
|
? () async {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart';
|
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
|
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
|
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
|
||||||
|
|
@ -19,7 +20,11 @@ class QrCodeScannerViewState extends State<QrCodeScannerView> {
|
||||||
_mainCameraController.setState = () {
|
_mainCameraController.setState = () {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
};
|
};
|
||||||
unawaited(_mainCameraController.selectCamera(0, true));
|
Permission.camera.isGranted.then((hasPermission) {
|
||||||
|
if (hasPermission && mounted) {
|
||||||
|
unawaited(_mainCameraController.selectCamera(0, true));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -44,10 +49,12 @@ class QrCodeScannerViewState extends State<QrCodeScannerView> {
|
||||||
onTapDown: _mainCameraController.onTapDown,
|
onTapDown: _mainCameraController.onTapDown,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
CameraPreviewControllerView(
|
Positioned.fill(
|
||||||
mainController: _mainCameraController,
|
child: CameraPreviewControllerView(
|
||||||
hideControllers: true,
|
mainController: _mainCameraController,
|
||||||
isVisible: true,
|
hideControllers: true,
|
||||||
|
isVisible: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart';
|
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
|
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
|
||||||
|
|
@ -21,7 +22,11 @@ class CameraSendToViewState extends State<CameraSendToView> {
|
||||||
_mainCameraController.setState = () {
|
_mainCameraController.setState = () {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
};
|
};
|
||||||
unawaited(_mainCameraController.selectCamera(0, true));
|
Permission.camera.isGranted.then((hasPermission) {
|
||||||
|
if (hasPermission && mounted) {
|
||||||
|
unawaited(_mainCameraController.selectCamera(0, true));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -46,10 +51,12 @@ class CameraSendToViewState extends State<CameraSendToView> {
|
||||||
onTapDown: _mainCameraController.onTapDown,
|
onTapDown: _mainCameraController.onTapDown,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
CameraPreviewControllerView(
|
Positioned.fill(
|
||||||
mainController: _mainCameraController,
|
child: CameraPreviewControllerView(
|
||||||
sendToGroup: widget.sendToGroup,
|
mainController: _mainCameraController,
|
||||||
isVisible: true,
|
sendToGroup: widget.sendToGroup,
|
||||||
|
isVisible: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
layers.add(FilterLayerData(key: GlobalKey()));
|
layers.add(FilterLayerData(key: GlobalKey()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.previewLink != null && widget.previewLink!.shouldGeneratePreview) {
|
if (widget.previewLink != null &&
|
||||||
|
widget.previewLink!.shouldGeneratePreview) {
|
||||||
layers.add(
|
layers.add(
|
||||||
LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!.url),
|
LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!.url),
|
||||||
);
|
);
|
||||||
|
|
@ -724,7 +725,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
if (widget.sendToGroup != null) const SizedBox(width: 10),
|
if (widget.sendToGroup != null) const SizedBox(width: 10),
|
||||||
if (widget.sendToGroup != null)
|
if (widget.sendToGroup != null)
|
||||||
MyButton(
|
MyButton(
|
||||||
variant: MyButtonVariant.secondaryDense,
|
variant: MyButtonVariant.secondaryMiddle,
|
||||||
onPressed: pushShareImageView,
|
onPressed: pushShareImageView,
|
||||||
child: const FaIcon(
|
child: const FaIcon(
|
||||||
FontAwesomeIcons.userPlus,
|
FontAwesomeIcons.userPlus,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:cryptography_plus/cryptography_plus.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
@ -13,7 +10,6 @@ import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/providers/purchases.provider.dart';
|
import 'package:twonly/src/providers/purchases.provider.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
import 'package:twonly/src/services/subscription.service.dart';
|
import 'package:twonly/src/services/subscription.service.dart';
|
||||||
import 'package:twonly/src/services/user.service.dart';
|
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
|
|
@ -32,7 +28,7 @@ class ChatListView extends StatefulWidget {
|
||||||
State<ChatListView> createState() => _ChatListViewState();
|
State<ChatListView> createState() => _ChatListViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatListViewState extends State<ChatListView> {
|
class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClientMixin<ChatListView> {
|
||||||
StreamSubscription<void>? _userSub;
|
StreamSubscription<void>? _userSub;
|
||||||
StreamSubscription<List<Group>>? _contactsSub;
|
StreamSubscription<List<Group>>? _contactsSub;
|
||||||
StreamSubscription<List<Contact>>? _contactsCountSub;
|
StreamSubscription<List<Contact>>? _contactsCountSub;
|
||||||
|
|
@ -126,32 +122,11 @@ class _ChatListViewState extends State<ChatListView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
||||||
final changeLog = await rootBundle.loadString('CHANGELOG.md');
|
|
||||||
final changeLogHash = (await compute(
|
|
||||||
Sha256().hash,
|
|
||||||
changeLog.codeUnits,
|
|
||||||
)).bytes;
|
|
||||||
if (!userService.currentUser.hideChangeLog &&
|
|
||||||
userService.currentUser.lastChangeLogHash.toString() !=
|
|
||||||
changeLogHash.toString()) {
|
|
||||||
await UserService.update((u) {
|
|
||||||
u.lastChangeLogHash = changeLogHash;
|
|
||||||
});
|
|
||||||
if (!mounted) return;
|
|
||||||
// only show changelog to people who already have contacts
|
|
||||||
// this prevents that this is shown directly after the user registered
|
|
||||||
if (_groupsNotPinned.isNotEmpty) {
|
|
||||||
await context.push(
|
|
||||||
Routes.settingsHelpChangelog,
|
|
||||||
extra: changeLog,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_contactsSub?.cancel();
|
_contactsSub?.cancel();
|
||||||
|
|
@ -165,6 +140,7 @@ class _ChatListViewState extends State<ChatListView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
final plan = context.watch<PurchasesProvider>().plan;
|
final plan = context.watch<PurchasesProvider>().plan;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:lottie/lottie.dart';
|
|
||||||
import 'package:mutex/mutex.dart';
|
import 'package:mutex/mutex.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
|
||||||
import 'package:screen_protector/screen_protector.dart';
|
import 'package:screen_protector/screen_protector.dart';
|
||||||
import 'package:twonly/globals.dart';
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
|
@ -29,12 +26,14 @@ import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
|
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
|
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_input.element.dart';
|
|
||||||
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
||||||
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
|
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
|
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart';
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart';
|
||||||
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart';
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
class MediaViewerView extends StatefulWidget {
|
class MediaViewerView extends StatefulWidget {
|
||||||
|
|
@ -64,6 +63,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
DateTime? canBeSeenUntil;
|
DateTime? canBeSeenUntil;
|
||||||
final ValueNotifier<double> progress = ValueNotifier(0);
|
final ValueNotifier<double> progress = ValueNotifier(0);
|
||||||
bool showSendTextMessageInput = false;
|
bool showSendTextMessageInput = false;
|
||||||
|
double maxBottomInset = 0;
|
||||||
|
DateTime? _lastTimeInputClosed;
|
||||||
final GlobalKey mediaWidgetKey = GlobalKey();
|
final GlobalKey mediaWidgetKey = GlobalKey();
|
||||||
|
|
||||||
bool imageSaved = false;
|
bool imageSaved = false;
|
||||||
|
|
@ -82,9 +83,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
final HashSet<String> _alreadyOpenedMediaIds = HashSet();
|
final HashSet<String> _alreadyOpenedMediaIds = HashSet();
|
||||||
|
|
||||||
bool _isTransitioning = false;
|
bool _isTransitioning = false;
|
||||||
bool _isZoomed = false;
|
|
||||||
late PageController _verticalPager;
|
|
||||||
final ValueNotifier<double> _backdropOpacityNotifier = ValueNotifier(1);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -95,12 +93,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
allMediaFiles = [widget.initialMessage!];
|
allMediaFiles = [widget.initialMessage!];
|
||||||
}
|
}
|
||||||
|
|
||||||
_verticalPager = PageController(initialPage: 1);
|
listenForUnopenedMedia(true);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated);
|
|
||||||
});
|
|
||||||
|
|
||||||
asyncLoadNextMedia(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -127,21 +120,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
);
|
);
|
||||||
textMessageController.dispose();
|
textMessageController.dispose();
|
||||||
|
|
||||||
_verticalPager
|
|
||||||
..removeListener(_onVerticalScrollUpdated)
|
|
||||||
..dispose();
|
|
||||||
_backdropOpacityNotifier.dispose();
|
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onVerticalScrollUpdated() {
|
|
||||||
if (!_verticalPager.hasClients) return;
|
|
||||||
final page = _verticalPager.page ?? 1.0;
|
|
||||||
final linearFraction = min(1, max(0, page)).toDouble();
|
|
||||||
_backdropOpacityNotifier.value = linearFraction * linearFraction;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _disposeVideoController() {
|
void _disposeVideoController() {
|
||||||
final listener = _videoListener;
|
final listener = _videoListener;
|
||||||
final controller = videoController;
|
final controller = videoController;
|
||||||
|
|
@ -160,7 +141,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
(ModalRoute.of(context)?.isCurrent ?? false);
|
(ModalRoute.of(context)?.isCurrent ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> asyncLoadNextMedia(bool firstRun) async {
|
Future<void> listenForUnopenedMedia(bool firstRun) async {
|
||||||
_subscription = twonlyDB.messagesDao
|
_subscription = twonlyDB.messagesDao
|
||||||
.watchMediaNotOpened(widget.group.groupId)
|
.watchMediaNotOpened(widget.group.groupId)
|
||||||
.listen((messages) async {
|
.listen((messages) async {
|
||||||
|
|
@ -174,7 +155,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.mediaId == currentMedia?.mediaFile.mediaId) {
|
if (msg.mediaId == currentMedia?.mediaFile.mediaId) {
|
||||||
// The update of the current Media in case of a download is done in loadCurrentMediaFile
|
// The update of the current Media in case of a download is done in loadAndDownloadCurrentMedia
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,13 +188,13 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
if (firstRun) {
|
if (firstRun) {
|
||||||
firstRun = false;
|
firstRun = false;
|
||||||
await loadCurrentMediaFile();
|
await loadAndDownloadCurrentMedia();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> nextMediaOrExit() async {
|
Future<void> advanceToNextMediaOrExit() async {
|
||||||
if (_isTransitioning) return;
|
if (_isTransitioning) return;
|
||||||
_isTransitioning = true;
|
_isTransitioning = true;
|
||||||
|
|
||||||
|
|
@ -245,17 +226,17 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await loadCurrentMediaFile();
|
await loadAndDownloadCurrentMedia();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) _isTransitioning = false;
|
if (mounted) _isTransitioning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadCurrentMediaFile({bool showTwonly = false}) async {
|
Future<void> loadAndDownloadCurrentMedia({bool showTwonly = false}) async {
|
||||||
if (!mounted || !context.mounted) return;
|
if (!mounted || !context.mounted) return;
|
||||||
if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) {
|
if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) {
|
||||||
return nextMediaOrExit();
|
return advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -293,7 +274,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
// Media file record no longer exists — skip to next or exit rather
|
// Media file record no longer exists — skip to next or exit rather
|
||||||
// than leaving the screen permanently black with no content/loader.
|
// than leaving the screen permanently black with no content/loader.
|
||||||
await downloadStateListener?.cancel();
|
await downloadStateListener?.cancel();
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (updated.downloadState != DownloadState.ready) {
|
if (updated.downloadState != DownloadState.ready) {
|
||||||
|
|
@ -308,7 +289,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if (mediaFile == null) {
|
if (mediaFile == null) {
|
||||||
// DB record gone — skip to next or exit.
|
// DB record gone — skip to next or exit.
|
||||||
await downloadStateListener?.cancel();
|
await downloadStateListener?.cancel();
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startDownloadMedia(mediaFile, true);
|
await startDownloadMedia(mediaFile, true);
|
||||||
|
|
@ -319,18 +300,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
|
|
||||||
await downloadStateListener?.cancel();
|
await downloadStateListener?.cancel();
|
||||||
try {
|
try {
|
||||||
await handleNextDownloadedMedia(showTwonly);
|
await initializeAndDisplayCurrentMedia(showTwonly);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
Log.error('handleNextDownloadedMedia failed: $e\n$st');
|
Log.error('initializeAndDisplayCurrentMedia failed: $e\n$st');
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
// start downloading all the other possible missing media files.
|
// start downloading all the other possible missing media files.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleNextDownloadedMedia(
|
Future<void> initializeAndDisplayCurrentMedia(bool showTwonly) async {
|
||||||
bool showTwonly,
|
|
||||||
) async {
|
|
||||||
if (allMediaFiles.isEmpty) return;
|
if (allMediaFiles.isEmpty) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_showDownloadingLoader = false;
|
_showDownloadingLoader = false;
|
||||||
|
|
@ -355,7 +334,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (!isAuth) {
|
if (!isAuth) {
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
displayTwonlyPresent = false;
|
displayTwonlyPresent = false;
|
||||||
|
|
@ -372,25 +351,55 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
displayTwonlyPresent = false;
|
displayTwonlyPresent = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!widget.group.isDirectChat) {
|
await _updateSenderInfo();
|
||||||
final sender = await twonlyDB.contactsDao.getContactById(
|
if (!mounted) return;
|
||||||
currentMessage!.senderId!,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
await _notifyMessageOpened(currentMediaLocal);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
if (sender != null) {
|
if (!currentMediaLocal.tempPath.existsSync()) {
|
||||||
_currentMediaSender =
|
Log.error('Temp media file not found...');
|
||||||
'${getContactDisplayName(sender)} (${widget.group.groupName})';
|
await handleMediaError(currentMediaLocal.mediaFile);
|
||||||
}
|
return advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server can now delete the encrypted bytes, as the users has sucessfully opened it.
|
||||||
|
unawaited(
|
||||||
|
apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentMediaLocal.mediaFile.type == MediaType.video) {
|
||||||
|
await _setupVideoPlayer(currentMediaLocal);
|
||||||
|
} else {
|
||||||
|
_setupImageTimer(currentMediaLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
currentMedia = currentMediaLocal;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateSenderInfo() async {
|
||||||
|
if (currentMessage == null || widget.group.isDirectChat) return;
|
||||||
|
final sender = await twonlyDB.contactsDao.getContactById(
|
||||||
|
currentMessage!.senderId!,
|
||||||
|
);
|
||||||
|
if (mounted && sender != null) {
|
||||||
|
_currentMediaSender =
|
||||||
|
'${getContactDisplayName(sender)} (${widget.group.groupName})';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _notifyMessageOpened(MediaFileService mediaLocal) async {
|
||||||
|
if (currentMessage == null) return;
|
||||||
var markAsOpenMessageIDs = [currentMessage!.messageId];
|
var markAsOpenMessageIDs = [currentMessage!.messageId];
|
||||||
|
|
||||||
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
|
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
|
||||||
currentMediaLocal.mediaFile.storedFileHash != null) {
|
mediaLocal.mediaFile.storedFileHash != null) {
|
||||||
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
|
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
|
||||||
currentMediaLocal.mediaFile.storedFileHash!,
|
mediaLocal.mediaFile.storedFileHash!,
|
||||||
currentMessage!.senderId!,
|
currentMessage!.senderId!,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -408,106 +417,82 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
currentMessage!.senderId!,
|
currentMessage!.senderId!,
|
||||||
markAsOpenMessageIDs,
|
markAsOpenMessageIDs,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
Future<void> _setupVideoPlayer(MediaFileService mediaLocal) async {
|
||||||
|
final controller = VideoPlayerController.file(
|
||||||
if (!currentMediaLocal.tempPath.existsSync()) {
|
mediaLocal.tempPath,
|
||||||
Log.error('Temp media file not found...');
|
videoPlayerOptions: VideoPlayerOptions(
|
||||||
await handleMediaError(currentMediaLocal.mediaFile);
|
mixWithOthers: mediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
||||||
return nextMediaOrExit();
|
),
|
||||||
}
|
|
||||||
|
|
||||||
// The server can now delete the encrypted bytes, as the users has sucessfully opened it.
|
|
||||||
unawaited(
|
|
||||||
apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var timerRequired = false;
|
await controller.setLooping(
|
||||||
|
mediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
||||||
|
);
|
||||||
|
|
||||||
if (currentMediaLocal.mediaFile.type == MediaType.video) {
|
if (!mounted) {
|
||||||
final controller = VideoPlayerController.file(
|
await controller.dispose();
|
||||||
currentMediaLocal.tempPath,
|
return;
|
||||||
videoPlayerOptions: VideoPlayerOptions(
|
}
|
||||||
// only mix in case the video can be played multiple times,
|
|
||||||
// otherwise stop the background music in case the video contains audio
|
await controller
|
||||||
mixWithOthers:
|
.initialize()
|
||||||
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
.then((_) {
|
||||||
|
if (!mounted || videoController != null) {
|
||||||
|
controller.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void listener() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final ctrl = videoController;
|
||||||
|
if (ctrl == null) return;
|
||||||
|
|
||||||
|
final duration = ctrl.value.duration.inSeconds;
|
||||||
|
if (duration > 0) {
|
||||||
|
progress.value = 1 - ctrl.value.position.inSeconds / duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
|
if (ctrl.value.position == ctrl.value.duration) {
|
||||||
|
advanceToNextMediaOrExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_videoListener = listener;
|
||||||
|
videoController = controller;
|
||||||
|
controller
|
||||||
|
..addListener(listener)
|
||||||
|
..play();
|
||||||
|
})
|
||||||
|
.catchError((Object err, StackTrace st) {
|
||||||
|
Log.error('Video player initialization error', err, st);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupImageTimer(MediaFileService mediaLocal) {
|
||||||
|
if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
|
canBeSeenUntil = clock.now().add(
|
||||||
|
Duration(
|
||||||
|
milliseconds: mediaLocal.mediaFile.displayLimitInMilliseconds!,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (mounted) {
|
||||||
await controller.setLooping(
|
startProgressTimer();
|
||||||
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
await controller.dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await controller
|
|
||||||
.initialize()
|
|
||||||
.then((_) {
|
|
||||||
if (!mounted || videoController != null) {
|
|
||||||
controller.dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void listener() {
|
|
||||||
if (!mounted) return;
|
|
||||||
final ctrl = videoController;
|
|
||||||
if (ctrl == null) return;
|
|
||||||
|
|
||||||
final duration = ctrl.value.duration.inSeconds;
|
|
||||||
if (duration > 0) {
|
|
||||||
progress.value = 1 - ctrl.value.position.inSeconds / duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds !=
|
|
||||||
null) {
|
|
||||||
if (ctrl.value.position == ctrl.value.duration) {
|
|
||||||
nextMediaOrExit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_videoListener = listener;
|
|
||||||
videoController = controller;
|
|
||||||
controller
|
|
||||||
..addListener(listener)
|
|
||||||
..play();
|
|
||||||
})
|
|
||||||
// ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error
|
|
||||||
.catchError(Log.error);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
} else {
|
|
||||||
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
|
||||||
canBeSeenUntil = clock.now().add(
|
|
||||||
Duration(
|
|
||||||
milliseconds:
|
|
||||||
currentMediaLocal.mediaFile.displayLimitInMilliseconds!,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
timerRequired = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
currentMedia = currentMediaLocal;
|
|
||||||
});
|
|
||||||
if (timerRequired) {
|
|
||||||
startTimer();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void startTimer() {
|
void startProgressTimer() {
|
||||||
nextMediaTimer?.cancel();
|
nextMediaTimer?.cancel();
|
||||||
progressTimer?.cancel();
|
progressTimer?.cancel();
|
||||||
if (canBeSeenUntil != null) {
|
if (canBeSeenUntil != null) {
|
||||||
nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () {
|
nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
nextMediaOrExit();
|
advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
|
progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
|
||||||
|
|
@ -653,7 +638,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
);
|
);
|
||||||
if (mounted &&
|
if (mounted &&
|
||||||
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
await nextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
} else {
|
} else {
|
||||||
await videoController?.play();
|
await videoController?.play();
|
||||||
}
|
}
|
||||||
|
|
@ -677,21 +662,63 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onTap() {
|
void _sendTextMessage() {
|
||||||
|
if (textMessageController.text.isNotEmpty) {
|
||||||
|
unawaited(
|
||||||
|
insertAndSendTextMessage(
|
||||||
|
widget.group.groupId,
|
||||||
|
textMessageController.text,
|
||||||
|
currentMessage!.messageId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
textMessageController.clear();
|
||||||
|
}
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
setState(() {
|
||||||
|
showSendTextMessageInput = false;
|
||||||
|
showShortReactions = false;
|
||||||
|
_lastTimeInputClosed = clock.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void onScreenTapped() {
|
||||||
|
if (_lastTimeInputClosed != null &&
|
||||||
|
clock.now().difference(_lastTimeInputClosed!) <
|
||||||
|
const Duration(milliseconds: 300)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (showSendTextMessageInput) {
|
if (showSendTextMessageInput) {
|
||||||
setState(() {
|
setState(() {
|
||||||
showShortReactions = false;
|
showShortReactions = false;
|
||||||
showSendTextMessageInput = false;
|
showSendTextMessageInput = false;
|
||||||
|
_lastTimeInputClosed = clock.now();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nextMediaOrExit();
|
advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
if (bottomInset > maxBottomInset) {
|
||||||
|
maxBottomInset = bottomInset;
|
||||||
|
} else if (bottomInset == 0 && maxBottomInset > 0) {
|
||||||
|
maxBottomInset = 0;
|
||||||
|
if (showSendTextMessageInput) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
showSendTextMessageInput = false;
|
||||||
|
showShortReactions = false;
|
||||||
|
_lastTimeInputClosed = clock.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
|
|
@ -700,93 +727,21 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if ((currentMedia != null || videoController != null) &&
|
if ((currentMedia != null || videoController != null) &&
|
||||||
(canBeSeenUntil == null || progress.value >= 0))
|
(canBeSeenUntil == null || progress.value >= 0))
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onScreenTapped,
|
||||||
onDoubleTap: (videoController == null) ? null : onTap,
|
onDoubleTap: (videoController == null) ? null : onScreenTapped,
|
||||||
child: MediaViewSizingHelper(
|
child: MediaViewSizingHelper(
|
||||||
bottomNavigation: bottomNavigation(),
|
bottomNavigation: bottomNavigation(),
|
||||||
requiredHeight: 55,
|
requiredHeight: 55,
|
||||||
child: Stack(
|
child: MediaContentRenderer(
|
||||||
children: [
|
currentMedia: currentMedia,
|
||||||
if (videoController != null)
|
videoController: videoController,
|
||||||
Positioned.fill(
|
loader: _loader(),
|
||||||
child: PhotoView.customChild(
|
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
|
||||||
minScale: PhotoViewComputedScale.contained,
|
|
||||||
backgroundDecoration: const BoxDecoration(
|
|
||||||
color: Colors.transparent,
|
|
||||||
),
|
|
||||||
scaleStateChangedCallback: (state) {
|
|
||||||
final zoomed =
|
|
||||||
state != PhotoViewScaleState.initial;
|
|
||||||
if (_isZoomed != zoomed) {
|
|
||||||
setState(() {
|
|
||||||
_isZoomed = zoomed;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: VideoPlayer(
|
|
||||||
videoController!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (currentMedia != null &&
|
|
||||||
(currentMedia!.mediaFile.type == MediaType.image ||
|
|
||||||
currentMedia!.mediaFile.type == MediaType.gif))
|
|
||||||
Positioned.fill(
|
|
||||||
child: PhotoView(
|
|
||||||
imageProvider: FileImage(
|
|
||||||
currentMedia!.tempPath,
|
|
||||||
),
|
|
||||||
loadingBuilder: (context, event) => _loader(),
|
|
||||||
backgroundDecoration: const BoxDecoration(
|
|
||||||
color: Colors.transparent,
|
|
||||||
),
|
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
|
||||||
minScale: PhotoViewComputedScale.contained,
|
|
||||||
scaleStateChangedCallback: (state) {
|
|
||||||
final zoomed =
|
|
||||||
state != PhotoViewScaleState.initial;
|
|
||||||
if (_isZoomed != zoomed) {
|
|
||||||
setState(() {
|
|
||||||
_isZoomed = zoomed;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.broken_image_outlined,
|
|
||||||
color: Colors.white38,
|
|
||||||
size: 64,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (displayTwonlyPresent)
|
if (displayTwonlyPresent)
|
||||||
Positioned.fill(
|
TwonlyPresentOverlay(
|
||||||
child: GestureDetector(
|
onTap: () => loadAndDownloadCurrentMedia(showTwonly: true),
|
||||||
onTap: () => loadCurrentMediaFile(showTwonly: true),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Lottie.asset(
|
|
||||||
'assets/animations/present.lottie.lottie',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.only(bottom: 200),
|
|
||||||
child: Text(
|
|
||||||
context.lang.mediaViewerTwonlyTapToOpen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (currentMedia != null &&
|
if (currentMedia != null &&
|
||||||
currentMedia?.mediaFile.downloadState != DownloadState.ready)
|
currentMedia?.mediaFile.downloadState != DownloadState.ready)
|
||||||
|
|
@ -836,63 +791,10 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showSendTextMessageInput)
|
if (showSendTextMessageInput)
|
||||||
Positioned(
|
MediaViewerMessageInput(
|
||||||
bottom: 0,
|
controller: textMessageController,
|
||||||
left: 0,
|
onSubmitted: (value) => _sendTextMessage(),
|
||||||
right: 0,
|
onSendPressed: _sendTextMessage,
|
||||||
child: Container(
|
|
||||||
color: context.color.surface,
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
bottom: 10,
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
top: 10,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: MyInput(
|
|
||||||
dense: true,
|
|
||||||
autofocus: true,
|
|
||||||
controller: textMessageController,
|
|
||||||
hintText: context.lang.chatListDetailInput,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
onSubmitted: (value) {
|
|
||||||
setState(() {
|
|
||||||
showSendTextMessageInput = false;
|
|
||||||
showShortReactions = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
MyIconButton(
|
|
||||||
icon: const FaIcon(
|
|
||||||
FontAwesomeIcons.solidPaperPlane,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
|
||||||
if (textMessageController.text.isNotEmpty) {
|
|
||||||
unawaited(
|
|
||||||
insertAndSendTextMessage(
|
|
||||||
widget.group.groupId,
|
|
||||||
textMessageController.text,
|
|
||||||
currentMessage!.messageId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
textMessageController.clear();
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
showSendTextMessageInput = false;
|
|
||||||
showShortReactions = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (currentMessage != null)
|
if (currentMessage != null)
|
||||||
AdditionalMessageContent(currentMessage!),
|
AdditionalMessageContent(currentMessage!),
|
||||||
|
|
@ -908,6 +810,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
setState(() {
|
setState(() {
|
||||||
showShortReactions = false;
|
showShortReactions = false;
|
||||||
showSendTextMessageInput = false;
|
showSendTextMessageInput = false;
|
||||||
|
_lastTimeInputClosed = clock.now();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
import 'package:twonly/src/database/tables/mediafiles.table.dart'
|
||||||
|
show MediaType;
|
||||||
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
class MediaContentRenderer extends StatelessWidget {
|
||||||
|
const MediaContentRenderer({
|
||||||
|
required this.currentMedia,
|
||||||
|
required this.videoController,
|
||||||
|
required this.loader,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MediaFileService? currentMedia;
|
||||||
|
final VideoPlayerController? videoController;
|
||||||
|
final Widget loader;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
if (videoController != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: PhotoView.customChild(
|
||||||
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
|
minScale: PhotoViewComputedScale.contained,
|
||||||
|
backgroundDecoration: const BoxDecoration(
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: VideoPlayer(
|
||||||
|
videoController!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (currentMedia != null &&
|
||||||
|
(currentMedia!.mediaFile.type == MediaType.image ||
|
||||||
|
currentMedia!.mediaFile.type == MediaType.gif))
|
||||||
|
Positioned.fill(
|
||||||
|
child: PhotoView(
|
||||||
|
imageProvider: FileImage(
|
||||||
|
currentMedia!.tempPath,
|
||||||
|
),
|
||||||
|
loadingBuilder: (context, event) => loader,
|
||||||
|
backgroundDecoration: const BoxDecoration(
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
|
minScale: PhotoViewComputedScale.contained,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
color: Colors.white38,
|
||||||
|
size: 64,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/my_input.element.dart';
|
||||||
|
|
||||||
|
class MediaViewerMessageInput extends StatelessWidget {
|
||||||
|
const MediaViewerMessageInput({
|
||||||
|
required this.controller,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.onSendPressed,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final VoidCallback onSendPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
color: context.color.surface,
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 10,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: MyInput(
|
||||||
|
dense: true,
|
||||||
|
autofocus: true,
|
||||||
|
controller: controller,
|
||||||
|
hintText: context.lang.chatListDetailInput,
|
||||||
|
onChanged: (value) {},
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
MyIconButton(
|
||||||
|
icon: const FaIcon(
|
||||||
|
FontAwesomeIcons.solidPaperPlane,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: onSendPressed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,6 @@ class ReactionButtons extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReactionButtonsState extends State<ReactionButtons> {
|
class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
int selectedShortReaction = -1;
|
|
||||||
final GlobalKey _keyEmojiPicker = GlobalKey();
|
final GlobalKey _keyEmojiPicker = GlobalKey();
|
||||||
bool _renderAnimations = false;
|
bool _renderAnimations = false;
|
||||||
|
|
||||||
|
|
@ -74,23 +73,40 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
? selectedEmojis.skip(6).toList()
|
? selectedEmojis.skip(6).toList()
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
final show = widget.show;
|
||||||
|
|
||||||
|
final targetBottom = widget.textInputFocused
|
||||||
|
? 50.0
|
||||||
|
: widget.mediaViewerDistanceFromBottom;
|
||||||
|
|
||||||
|
final bottomPosition = show
|
||||||
|
? targetBottom
|
||||||
|
: widget.mediaViewerDistanceFromBottom - 20;
|
||||||
|
|
||||||
|
final targetOpacity = show ? 1.0 : 0.0;
|
||||||
|
final isIgnoring = !show;
|
||||||
|
|
||||||
|
final positionDuration = show
|
||||||
|
? const Duration(milliseconds: 200)
|
||||||
|
: Duration.zero;
|
||||||
|
|
||||||
|
final opacityDuration = show
|
||||||
|
? const Duration(milliseconds: 150)
|
||||||
|
: Duration.zero;
|
||||||
|
|
||||||
return AnimatedPositioned(
|
return AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 200), // Animation duration
|
duration: positionDuration, // Animation duration
|
||||||
bottom: widget.show
|
bottom: bottomPosition,
|
||||||
? (widget.textInputFocused
|
|
||||||
? 50
|
|
||||||
: widget.mediaViewerDistanceFromBottom)
|
|
||||||
: widget.mediaViewerDistanceFromBottom - 20,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
curve: Curves.linearToEaseOut,
|
curve: Curves.linearToEaseOut,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !widget.show,
|
ignoring: isIgnoring,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: widget.show ? 1.0 : 0.0, // Fade in/out
|
opacity: targetOpacity, // Fade in/out
|
||||||
duration: Duration(milliseconds: widget.show ? 150 : 50),
|
duration: opacityDuration,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent,
|
color: Colors.transparent,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lottie/lottie.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
|
class TwonlyPresentOverlay extends StatelessWidget {
|
||||||
|
const TwonlyPresentOverlay({
|
||||||
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Lottie.asset(
|
||||||
|
'assets/animations/present.lottie.lottie',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(bottom: 200),
|
||||||
|
child: Text(
|
||||||
|
context.lang.mediaViewerTwonlyTapToOpen,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,12 @@ import 'dart:async';
|
||||||
import 'package:app_links/app_links.dart';
|
import 'package:app_links/app_links.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:flutter_sharing_intent/model/sharing_file.dart';
|
import 'package:flutter_sharing_intent/model/sharing_file.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/providers/routing.provider.dart';
|
import 'package:twonly/src/providers/routing.provider.dart';
|
||||||
|
|
@ -38,6 +41,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
double _offsetFromOne = 0;
|
double _offsetFromOne = 0;
|
||||||
bool _isBottomNavVisible = true;
|
bool _isBottomNavVisible = true;
|
||||||
Timer? _disableCameraTimer;
|
Timer? _disableCameraTimer;
|
||||||
|
bool _startPreloading = false;
|
||||||
|
|
||||||
final MainCameraController _mainCameraController = MainCameraController();
|
final MainCameraController _mainCameraController = MainCameraController();
|
||||||
late final PageController _homeViewPageController;
|
late final PageController _homeViewPageController;
|
||||||
|
|
@ -73,7 +77,12 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_activePageIdx = index;
|
_activePageIdx = index;
|
||||||
|
_offsetFromOne = 1.0 - index;
|
||||||
|
_offsetRatio = _offsetFromOne.abs();
|
||||||
});
|
});
|
||||||
|
if (index != 1) {
|
||||||
|
unawaited(_mainCameraController.closeCamera());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_selectNotificationSub = selectNotificationStream.stream.listen((
|
_selectNotificationSub = selectNotificationStream.stream.listen((
|
||||||
|
|
@ -95,8 +104,8 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (initialPage == 1) {
|
if (initialPage == 1) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
Permission.camera.isGranted.then((hasPermission) {
|
||||||
if (_isViewActive()) {
|
if (hasPermission && mounted) {
|
||||||
unawaited(_mainCameraController.selectCamera(0, true));
|
unawaited(_mainCameraController.selectCamera(0, true));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -104,20 +113,26 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
|
|
||||||
unawaited(_initAsync());
|
unawaited(_initAsync());
|
||||||
|
|
||||||
|
void handleShareLink(Uri uri) {
|
||||||
|
routerProvider.go(Routes.home);
|
||||||
|
streamHomeViewPageIndex.add(1);
|
||||||
|
_mainCameraController.setSharedLinkForPreview(uri);
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to all events (initial link and further)
|
// Subscribe to all events (initial link and further)
|
||||||
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
|
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Log.info('Got link via app links: ${uri.scheme}');
|
Log.info('Got link via app links: ${uri.scheme}');
|
||||||
if (!await handleIntentUrl(context, uri)) {
|
if (!await handleIntentUrl(context, uri)) {
|
||||||
if (uri.scheme.startsWith('http')) {
|
if (uri.scheme.startsWith('http')) {
|
||||||
_mainCameraController.setSharedLinkForPreview(uri);
|
handleShareLink(uri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_intentStreamSub = initIntentStreams(
|
_intentStreamSub = initIntentStreams(
|
||||||
context,
|
context,
|
||||||
_mainCameraController.setSharedLinkForPreview,
|
handleShareLink,
|
||||||
);
|
);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|
@ -126,6 +141,13 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
widget.initialPage == 0) {
|
widget.initialPage == 0) {
|
||||||
streamHomeViewPageIndex.add(0);
|
streamHomeViewPageIndex.add(0);
|
||||||
}
|
}
|
||||||
|
Future.delayed(const Duration(seconds: 1), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_startPreloading = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,11 +178,8 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
payload.startsWith(Routes.chats) &&
|
payload.startsWith(Routes.chats) &&
|
||||||
payload != Routes.chats) {
|
payload != Routes.chats) {
|
||||||
routerProvider.go(payload);
|
routerProvider.go(payload);
|
||||||
streamHomeViewPageIndex.add(0);
|
|
||||||
}
|
|
||||||
if (payload == Routes.chats) {
|
|
||||||
streamHomeViewPageIndex.add(0);
|
|
||||||
}
|
}
|
||||||
|
streamHomeViewPageIndex.add(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,9 +221,12 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
super.didChangeAppLifecycleState(state);
|
super.didChangeAppLifecycleState(state);
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
if (_offsetRatio < 1 &&
|
if (AppState.hasCameraPermissions &&
|
||||||
|
_offsetRatio < 1 &&
|
||||||
!_mainCameraController.isSharePreviewIsShown &&
|
!_mainCameraController.isSharePreviewIsShown &&
|
||||||
_isViewActive()) {
|
_isViewActive() &&
|
||||||
|
_mainCameraController.cameraController == null &&
|
||||||
|
!_mainCameraController.initCameraStarted) {
|
||||||
unawaited(
|
unawaited(
|
||||||
_mainCameraController.selectCamera(
|
_mainCameraController.selectCamera(
|
||||||
_mainCameraController.selectedCameraDetails.cameraId,
|
_mainCameraController.selectedCameraDetails.cameraId,
|
||||||
|
|
@ -212,8 +234,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (state == AppLifecycleState.inactive ||
|
} else if (state == AppLifecycleState.paused) {
|
||||||
state == AppLifecycleState.paused) {
|
|
||||||
unawaited(_mainCameraController.closeCamera());
|
unawaited(_mainCameraController.closeCamera());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,14 +276,19 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.depth == 0 && notification is ScrollUpdateNotification) {
|
if (notification.depth == 0) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_offsetFromOne = 1.0 - (_homeViewPageController.page ?? 0);
|
_offsetFromOne = 1.0 - (_homeViewPageController.page ?? 0);
|
||||||
_offsetRatio = _offsetFromOne.abs();
|
_offsetRatio = _offsetFromOne.abs();
|
||||||
|
final pageIndex = _homeViewPageController.page?.round();
|
||||||
|
if (pageIndex != null && pageIndex != _activePageIdx) {
|
||||||
|
_activePageIdx = pageIndex;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_mainCameraController.cameraController == null &&
|
if (AppState.hasCameraPermissions &&
|
||||||
|
_mainCameraController.cameraController == null &&
|
||||||
!_mainCameraController.initCameraStarted &&
|
!_mainCameraController.initCameraStarted &&
|
||||||
_offsetRatio < 1 &&
|
_offsetRatio < 1 &&
|
||||||
_isViewActive()) {
|
_isViewActive()) {
|
||||||
|
|
@ -301,17 +327,21 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
NotificationListener<ScrollNotification>(
|
NotificationListener<ScrollNotification>(
|
||||||
onNotification: _onPageView,
|
onNotification: _onPageView,
|
||||||
child: Positioned.fill(
|
child: Positioned.fill(
|
||||||
child: PageView(
|
child: CustomScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
physics: const PageScrollPhysics(),
|
||||||
controller: _homeViewPageController,
|
controller: _homeViewPageController,
|
||||||
onPageChanged: (index) {
|
scrollCacheExtent: _startPreloading
|
||||||
setState(() {
|
? const ScrollCacheExtent.viewport(1)
|
||||||
_activePageIdx = index;
|
: null,
|
||||||
});
|
slivers: [
|
||||||
},
|
SliverFillViewport(
|
||||||
children: [
|
delegate: SliverChildListDelegate([
|
||||||
const ChatListView(),
|
const ChatListView(),
|
||||||
Container(),
|
Container(),
|
||||||
const MemoriesView(),
|
const MemoriesView(),
|
||||||
|
]),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/model/memory_item.model.dart';
|
import 'package:twonly/src/model/memory_item.model.dart';
|
||||||
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart';
|
import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart';
|
||||||
|
|
||||||
class MemoriesThumbnailComp extends StatefulWidget {
|
class MemoriesThumbnailComp extends StatefulWidget {
|
||||||
const MemoriesThumbnailComp({
|
const MemoriesThumbnailComp({
|
||||||
required this.galleryItem,
|
required this.galleryItem,
|
||||||
|
|
@ -28,14 +29,7 @@ class MemoriesThumbnailComp extends StatefulWidget {
|
||||||
State<MemoriesThumbnailComp> createState() => _MemoriesThumbnailCompState();
|
State<MemoriesThumbnailComp> createState() => _MemoriesThumbnailCompState();
|
||||||
}
|
}
|
||||||
|
|
||||||
final Set<String> _alreadyAnimatedIds = {};
|
class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
|
|
||||||
class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final AnimationController _scaleController;
|
|
||||||
late final Animation<double> _scaleAnimation;
|
|
||||||
late final Animation<Offset> _slideAnimation;
|
|
||||||
|
|
||||||
ImageProvider? _imageProvider;
|
ImageProvider? _imageProvider;
|
||||||
ImageStream? _imageStream;
|
ImageStream? _imageStream;
|
||||||
ImageInfo? _imageInfo;
|
ImageInfo? _imageInfo;
|
||||||
|
|
@ -44,40 +38,6 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scaleController = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 350),
|
|
||||||
);
|
|
||||||
_scaleAnimation = Tween<double>(begin: 0.94, end: 1).animate(
|
|
||||||
CurvedAnimation(parent: _scaleController, curve: Curves.easeOutCubic),
|
|
||||||
);
|
|
||||||
_slideAnimation =
|
|
||||||
Tween<Offset>(
|
|
||||||
begin: const Offset(0, 0.125),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(
|
|
||||||
CurvedAnimation(parent: _scaleController, curve: Curves.easeOutCubic),
|
|
||||||
);
|
|
||||||
|
|
||||||
final mediaId = widget.galleryItem.mediaService.mediaFile.mediaId;
|
|
||||||
final shouldAnimate =
|
|
||||||
widget.index < 20 && !_alreadyAnimatedIds.contains(mediaId);
|
|
||||||
|
|
||||||
if (shouldAnimate) {
|
|
||||||
_alreadyAnimatedIds.add(mediaId);
|
|
||||||
final delayMs = widget.index * 10;
|
|
||||||
if (delayMs > 0) {
|
|
||||||
Future.delayed(Duration(milliseconds: delayMs), () {
|
|
||||||
if (mounted) {
|
|
||||||
_scaleController.forward();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
_scaleController.forward();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_scaleController.value = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
_listener = ImageStreamListener(
|
_listener = ImageStreamListener(
|
||||||
(info, _) {
|
(info, _) {
|
||||||
|
|
@ -101,8 +61,11 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
||||||
|
|
||||||
void _resolveImage() {
|
void _resolveImage() {
|
||||||
final media = widget.galleryItem.mediaService;
|
final media = widget.galleryItem.mediaService;
|
||||||
final hasThumbnail = media.thumbnailPath.existsSync() && media.thumbnailPath.lengthSync() > 0;
|
final hasThumbnail =
|
||||||
final hasStored = media.storedPath.existsSync() && media.storedPath.lengthSync() > 0;
|
media.thumbnailPath.existsSync() &&
|
||||||
|
media.thumbnailPath.lengthSync() > 0;
|
||||||
|
final hasStored =
|
||||||
|
media.storedPath.existsSync() && media.storedPath.lengthSync() > 0;
|
||||||
final isImageOrGif =
|
final isImageOrGif =
|
||||||
media.mediaFile.type == MediaType.image ||
|
media.mediaFile.type == MediaType.image ||
|
||||||
media.mediaFile.type == MediaType.gif;
|
media.mediaFile.type == MediaType.gif;
|
||||||
|
|
@ -136,7 +99,6 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_scaleController.dispose();
|
|
||||||
_imageStream?.removeListener(_listener);
|
_imageStream?.removeListener(_listener);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -169,80 +131,71 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: SlideTransition(
|
child: SelectableThumbnailComp(
|
||||||
position: _slideAnimation,
|
isSelected: widget.isSelected,
|
||||||
child: ScaleTransition(
|
selectionMode: widget.selectionMode,
|
||||||
scale: _scaleAnimation,
|
child: Stack(
|
||||||
child: FadeTransition(
|
fit: StackFit.expand,
|
||||||
opacity: _scaleController,
|
children: [
|
||||||
child: SelectableThumbnailComp(
|
if (cachedInfo != null)
|
||||||
isSelected: widget.isSelected,
|
RawImage(
|
||||||
selectionMode: widget.selectionMode,
|
image: cachedInfo.image,
|
||||||
child: Stack(
|
fit: BoxFit.cover,
|
||||||
fit: StackFit.expand,
|
)
|
||||||
children: [
|
else if (_imageProvider != null)
|
||||||
if (cachedInfo != null)
|
Image(
|
||||||
RawImage(
|
image: _imageProvider!,
|
||||||
image: cachedInfo.image,
|
fit: BoxFit.cover,
|
||||||
fit: BoxFit.cover,
|
gaplessPlayback: true,
|
||||||
)
|
errorBuilder: (context, error, stackTrace) {
|
||||||
else if (_imageProvider != null)
|
return ColoredBox(
|
||||||
Image(
|
color: Colors.grey.shade200,
|
||||||
image: _imageProvider!,
|
child: const Center(
|
||||||
fit: BoxFit.cover,
|
child: FaIcon(
|
||||||
gaplessPlayback: true,
|
FontAwesomeIcons.image,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
color: Colors.black26,
|
||||||
return ColoredBox(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child: const Center(
|
|
||||||
child: FaIcon(
|
|
||||||
FontAwesomeIcons.image,
|
|
||||||
color: Colors.black26,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else
|
|
||||||
ColoredBox(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child: const Center(
|
|
||||||
child: FaIcon(
|
|
||||||
FontAwesomeIcons.image,
|
|
||||||
color: Colors.black26,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isVideo)
|
);
|
||||||
const Positioned.fill(
|
},
|
||||||
child: Center(
|
)
|
||||||
child: FaIcon(
|
else
|
||||||
FontAwesomeIcons.circlePlay,
|
ColoredBox(
|
||||||
color: Colors.white,
|
color: Colors.grey.shade200,
|
||||||
size: 32,
|
child: const Center(
|
||||||
shadows: [
|
child: FaIcon(
|
||||||
Shadow(color: Colors.black54, blurRadius: 6),
|
FontAwesomeIcons.image,
|
||||||
],
|
color: Colors.black26,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (media.mediaFile.isFavorite)
|
|
||||||
const Positioned(
|
|
||||||
bottom: 6,
|
|
||||||
left: 6,
|
|
||||||
child: Icon(
|
|
||||||
Icons.favorite,
|
|
||||||
color: Colors.redAccent,
|
|
||||||
size: 16,
|
|
||||||
shadows: [
|
|
||||||
Shadow(color: Colors.black54, blurRadius: 4),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
if (isVideo)
|
||||||
),
|
const Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: FaIcon(
|
||||||
|
FontAwesomeIcons.circlePlay,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
shadows: [
|
||||||
|
Shadow(color: Colors.black54, blurRadius: 6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (media.mediaFile.isFavorite)
|
||||||
|
const Positioned(
|
||||||
|
bottom: 6,
|
||||||
|
left: 6,
|
||||||
|
child: Icon(
|
||||||
|
Icons.favorite,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
size: 16,
|
||||||
|
shadows: [
|
||||||
|
Shadow(color: Colors.black54, blurRadius: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class MemoriesView extends StatefulWidget {
|
||||||
State<MemoriesView> createState() => MemoriesViewState();
|
State<MemoriesView> createState() => MemoriesViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemoriesViewState extends State<MemoriesView> {
|
class MemoriesViewState extends State<MemoriesView> with AutomaticKeepAliveClientMixin<MemoriesView> {
|
||||||
late final MemoriesService _service;
|
late final MemoriesService _service;
|
||||||
final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(null);
|
final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(null);
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
@ -38,6 +38,9 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
_activeMediaIdNotifier.addListener(_onActiveMediaChanged);
|
_activeMediaIdNotifier.addListener(_onActiveMediaChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_activeMediaIdNotifier.removeListener(_onActiveMediaChanged);
|
_activeMediaIdNotifier.removeListener(_onActiveMediaChanged);
|
||||||
|
|
@ -307,11 +310,18 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
final media = item.mediaService;
|
final media = item.mediaService;
|
||||||
if (media.mediaFile.type == MediaType.video) {
|
if (media.mediaFile.type == MediaType.video) {
|
||||||
await saveVideoToGallery(media.storedPath.path);
|
await saveVideoToGallery(
|
||||||
|
media.storedPath.path,
|
||||||
|
name: media.mediaFile.mediaId,
|
||||||
|
);
|
||||||
} else if (media.mediaFile.type == MediaType.image ||
|
} else if (media.mediaFile.type == MediaType.image ||
|
||||||
media.mediaFile.type == MediaType.gif) {
|
media.mediaFile.type == MediaType.gif) {
|
||||||
final imageBytes = await media.storedPath.readAsBytes();
|
final imageBytes = await media.storedPath.readAsBytes();
|
||||||
await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt);
|
await saveImageToGallery(
|
||||||
|
imageBytes,
|
||||||
|
createdAt: media.mediaFile.createdAt,
|
||||||
|
name: media.mediaFile.mediaId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setProgress((i + 1) / selectedList.length);
|
setProgress((i + 1) / selectedList.length);
|
||||||
|
|
@ -369,6 +379,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
|
|
|
||||||
|
|
@ -193,11 +193,18 @@ class _SynchronizedImageViewerScreenState
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (item.mediaFile.type == MediaType.video) {
|
if (item.mediaFile.type == MediaType.video) {
|
||||||
await saveVideoToGallery(item.storedPath.path);
|
await saveVideoToGallery(
|
||||||
|
item.storedPath.path,
|
||||||
|
name: item.mediaFile.mediaId,
|
||||||
|
);
|
||||||
} else if (item.mediaFile.type == MediaType.image ||
|
} else if (item.mediaFile.type == MediaType.image ||
|
||||||
item.mediaFile.type == MediaType.gif) {
|
item.mediaFile.type == MediaType.gif) {
|
||||||
final imageBytes = await item.storedPath.readAsBytes();
|
final imageBytes = await item.storedPath.readAsBytes();
|
||||||
await saveImageToGallery(imageBytes, createdAt: item.mediaFile.createdAt);
|
await saveImageToGallery(
|
||||||
|
imageBytes,
|
||||||
|
createdAt: item.mediaFile.createdAt,
|
||||||
|
name: item.mediaFile.mediaId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showSnackbar(
|
showSnackbar(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/services/android_photo_picker.service.dart';
|
import 'package:twonly/src/services/android_photo_picker.service.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart' show ShortCutsExtension, sha256File;
|
import 'package:twonly/src/utils/misc.dart' show ShortCutsExtension, sha256File;
|
||||||
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
|
|
@ -84,8 +85,8 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
|
|
||||||
final hash = Uint8List.fromList(sha256.convert(bytes).bytes);
|
final hash = Uint8List.fromList(sha256.convert(bytes).bytes);
|
||||||
|
|
||||||
final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
||||||
if (exsits.isNotEmpty) {
|
if (exists.isNotEmpty) {
|
||||||
duplicated += 1;
|
duplicated += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -345,8 +346,8 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
|
|
||||||
final hash = Uint8List.fromList(await sha256File(file));
|
final hash = Uint8List.fromList(await sha256File(file));
|
||||||
|
|
||||||
final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
||||||
if (exsits.isNotEmpty) {
|
if (exists.isNotEmpty) {
|
||||||
duplicated += 1;
|
duplicated += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -363,14 +364,50 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
type = MediaType.image;
|
type = MediaType.image;
|
||||||
}
|
}
|
||||||
|
|
||||||
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
MediaFile? mediaFile;
|
||||||
MediaFilesCompanion(
|
var isRestored = false;
|
||||||
type: Value(type),
|
|
||||||
createdAt: Value(createdAt),
|
if (Platform.isIOS) {
|
||||||
storedFileHash: Value(hash),
|
try {
|
||||||
stored: const Value(true),
|
final assetName = await asset.titleAsync;
|
||||||
),
|
final dotIndex = assetName.lastIndexOf('.');
|
||||||
);
|
final baseName = dotIndex != -1 ? assetName.substring(0, dotIndex) : assetName;
|
||||||
|
|
||||||
|
final uuidRegex = RegExp(
|
||||||
|
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
|
||||||
|
);
|
||||||
|
if (uuidRegex.hasMatch(baseName)) {
|
||||||
|
final existing = await twonlyDB.mediaFilesDao.getMediaFileById(baseName);
|
||||||
|
if (existing != null) {
|
||||||
|
final mediaService = MediaFileService(existing);
|
||||||
|
if (!mediaService.storedPath.existsSync()) {
|
||||||
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
baseName,
|
||||||
|
MediaFilesCompanion(
|
||||||
|
storedFileHash: Value(hash),
|
||||||
|
stored: const Value(true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(baseName);
|
||||||
|
isRestored = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Error checking iOS asset UUID name: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRestored) {
|
||||||
|
mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||||
|
MediaFilesCompanion(
|
||||||
|
type: Value(type),
|
||||||
|
createdAt: Value(createdAt),
|
||||||
|
storedFileHash: Value(hash),
|
||||||
|
stored: const Value(true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (mediaFile != null) {
|
if (mediaFile != null) {
|
||||||
final mediaService = MediaFileService(mediaFile);
|
final mediaService = MediaFileService(mediaFile);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/services/user.service.dart';
|
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
List<Widget> parseMarkdown(BuildContext context, String markdown) {
|
List<Widget> parseMarkdown(BuildContext context, String markdown) {
|
||||||
|
|
@ -107,20 +106,6 @@ class _ChangeLogViewState extends State<ChangeLogView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomAppBar(
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(context.lang.openChangeLog),
|
|
||||||
Switch.adaptive(
|
|
||||||
value: !userService.currentUser.hideChangeLog,
|
|
||||||
onChanged: (_) => UserService.update(
|
|
||||||
(u) => u.hideChangeLog = !u.hideChangeLog,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 0.3.1+145
|
version: 0.3.3+147
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue