This commit is contained in:
otsmr 2025-02-13 23:03:13 +01:00
parent baae1b04c0
commit 7db9daf41d
10 changed files with 388 additions and 138 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:provider/provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/components/connection_state.dart';
@ -7,7 +5,6 @@ import 'package:twonly/src/providers/contacts_change_provider.dart';
import 'package:twonly/src/providers/download_change_provider.dart';
import 'package:twonly/src/providers/messages_change_provider.dart';
import 'package:twonly/src/providers/settings_change_provider.dart';
import 'package:twonly/src/tasks/websocket_foreground_task.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/onboarding/onboarding_view.dart';
import 'package:twonly/src/views/home_view.dart';
@ -88,37 +85,37 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
apiProvider.connect();
}
Future<void> _requestPermissions() async {
// Android 13+, you need to allow notification permission to display foreground service notification.
//
// iOS: If you need notification, ask for permission.
final NotificationPermission notificationPermission =
await FlutterForegroundTask.checkNotificationPermission();
if (notificationPermission != NotificationPermission.granted) {
await FlutterForegroundTask.requestNotificationPermission();
}
if (Platform.isAndroid) {
// Android 12+, there are restrictions on starting a foreground service.
//
// To restart the service on device reboot or unexpected problem, you need to allow below permission.
if (!await FlutterForegroundTask.isIgnoringBatteryOptimizations) {
// This function requires `android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission.
await FlutterForegroundTask.requestIgnoreBatteryOptimization();
}
// Use this utility only if you provide services that require long-term survival,
// such as exact alarm service, healthcare service, or Bluetooth communication.
//
// This utility requires the "android.permission.SCHEDULE_EXACT_ALARM" permission.
// Using this permission may make app distribution difficult due to Google policy.
// if (!await FlutterForegroundTask.canScheduleExactAlarms) {
// When you call this function, will be gone to the settings page.
// So you need to explain to the user why set it.
// await FlutterForegroundTask.openAlarmsAndRemindersSettings();
// Future<void> _requestPermissions() async {
// // Android 13+, you need to allow notification permission to display foreground service notification.
// //
// // iOS: If you need notification, ask for permission.
// final NotificationPermission notificationPermission =
// await FlutterForegroundTask.checkNotificationPermission();
// if (notificationPermission != NotificationPermission.granted) {
// await FlutterForegroundTask.requestNotificationPermission();
// }
// if (Platform.isAndroid) {
// // Android 12+, there are restrictions on starting a foreground service.
// //
// // To restart the service on device reboot or unexpected problem, you need to allow below permission.
// if (!await FlutterForegroundTask.isIgnoringBatteryOptimizations) {
// // This function requires `android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission.
// await FlutterForegroundTask.requestIgnoreBatteryOptimization();
// }
// // Use this utility only if you provide services that require long-term survival,
// // such as exact alarm service, healthcare service, or Bluetooth communication.
// //
// // This utility requires the "android.permission.SCHEDULE_EXACT_ALARM" permission.
// // Using this permission may make app distribution difficult due to Google policy.
// // if (!await FlutterForegroundTask.canScheduleExactAlarms) {
// // When you call this function, will be gone to the settings page.
// // So you need to explain to the user why set it.
// // await FlutterForegroundTask.openAlarmsAndRemindersSettings();
// // }
// }
// }
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {

View file

@ -10,11 +10,12 @@ class EmojiAnimation extends StatelessWidget {
"": "red_heart.json",
"💪": "💪.json",
"🔥": "🔥.json",
"🤠": "🤠.json",
"🤯": "🤯.json",
"🥰": "🥰.json",
"😂": "😂.json",
"😭": "😭.json",
"🤯": "🤯.json",
"🥰": "🥰.json",
"🤠": "🤠.json",
"❤️‍🔥": "red_heart_fire.json"
};
const EmojiAnimation({super.key, required this.emoji});
@ -34,8 +35,45 @@ class EmojiAnimation extends StatelessWidget {
} else {
return Text(
emoji,
style: TextStyle(fontSize: 100), // Adjust the size as needed
style: TextStyle(fontSize: 15), // Adjust the size as needed
);
}
}
}
class EmojiAnimationFlying extends StatelessWidget {
final String emoji;
final Duration duration;
final double startPosition;
final int size;
const EmojiAnimationFlying({
super.key,
required this.emoji,
required this.duration,
required this.startPosition,
required this.size,
});
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: startPosition, end: 1), // Adjust end value as needed
duration: duration,
curve: Curves.linearToEaseOut,
builder: (context, value, child) {
return Padding(
padding: EdgeInsets.only(bottom: 20 * value),
child: Container(
// opacity: 1 - value,
child: SizedBox(
width: size + 30 * value,
child: EmojiAnimation(emoji: emoji),
),
),
);
},
);
}
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/components/animate_icon.dart';
import 'package:twonly/src/model/contacts_model.dart';
class FlameCounterWidget extends StatelessWidget {
@ -26,9 +27,12 @@ class FlameCounterWidget extends StatelessWidget {
flameCounter.toString(),
style: const TextStyle(fontSize: 13),
),
Text(
(maxTotalMediaCounter == user.totalMediaCounter) ? "❤️‍🔥" : "🔥",
style: const TextStyle(fontSize: 14),
SizedBox(
height: 15,
child: EmojiAnimation(
emoji: (maxTotalMediaCounter == user.totalMediaCounter)
? "❤️‍🔥"
: "🔥"),
),
],
);

View file

@ -40,6 +40,7 @@
"chatListViewSearchUserNameBtn": "Add your first twonly contact!",
"chatListViewSendFirstTwonly": "Send your first twonly!",
"chatListDetailInput": "Type a message",
"mediaViewerAuthReason": "Please authenticate to see this twonly!",
"messageSendState_Received": "Received",
"messageSendState_Opened": "Opened",
"messageSendState_Send": "Send",

View file

@ -41,6 +41,16 @@ class DbMessage {
bool get messageReceived => messageOtherId != null;
bool isRealTwonly() {
final content = messageContent;
if (content is MediaMessageContent) {
if (content.isRealTwonly) {
return true;
}
}
return false;
}
bool isMedia() {
return messageContent is MediaMessageContent;
}

View file

@ -2,8 +2,10 @@ import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:gal/gal.dart';
import 'package:local_auth/local_auth.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -203,3 +205,22 @@ Duration calculateTimeDifference(DateTime now, DateTime startTime) {
// Calculate the difference
return nowInUTC.difference(startTimeInUTC);
}
Future<bool> authenticateUser(String localizedReason,
{bool force = true}) async {
try {
final LocalAuthentication auth = LocalAuthentication();
bool didAuthenticate = await auth.authenticate(
localizedReason: localizedReason,
options: const AuthenticationOptions(useErrorDialogs: false));
if (didAuthenticate) {
return true;
}
} on PlatformException catch (e) {
debugPrint(e.toString());
if (!force) {
return true;
}
}
return false;
}

View file

@ -163,6 +163,7 @@ class _ChatItemDetailsViewState extends State<ChatItemDetailsView> {
context
.read<MessagesChangeProvider>()
.loadMessagesForUser(user.userId.toInt(), force: true);
setState(() {});
}
Future _sendMessage() async {

View file

@ -2,17 +2,20 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:local_auth/local_auth.dart';
import 'package:lottie/lottie.dart';
import 'package:no_screenshot/no_screenshot.dart';
import 'package:provider/provider.dart';
import 'package:twonly/src/components/animate_icon.dart';
import 'package:twonly/src/components/media_view_sizing.dart';
import 'package:twonly/src/model/contacts_model.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/model/messages_model.dart';
import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/providers/messages_change_provider.dart';
import 'package:twonly/src/providers/send_next_media_to.dart';
import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_item_details_view.dart';
import 'package:twonly/src/views/home_view.dart';
final _noScreenshot = NoScreenshot.instance;
@ -27,82 +30,142 @@ class MediaViewerView extends StatefulWidget {
}
class _MediaViewerViewState extends State<MediaViewerView> {
Uint8List? _imageByte;
Timer? nextMediaTimer;
Timer? progressTimer;
bool showShortReactions = false;
int selectedShortReaction = -1;
// current image related
Uint8List? imageBytes;
DateTime? canBeSeenUntil;
int maxShowTime = 999999;
bool isRealTwonly = false;
double progress = 0;
Timer? _timer;
Timer? _timer2;
// DateTime opened;
bool isRealTwonly = false;
bool isDownloading = false;
List<DbMessage> allMediaFiles = [];
@override
void initState() {
super.initState();
final content = widget.message.messageContent;
if (content is MediaMessageContent) {
if (content.isRealTwonly) {
isRealTwonly = true;
}
}
loadMedia();
allMediaFiles = [widget.message];
asyncLoadNextMedia();
loadCurrentMediaFile();
}
Future loadMedia({bool force = false}) async {
bool result = await _noScreenshot.screenshotOff();
debugPrint('Screenshot Off: $result');
final content = widget.message.messageContent;
if (content is MediaMessageContent) {
if (content.isRealTwonly) {
if (!force) {
Future asyncLoadNextMedia() async {
await context
.read<MessagesChangeProvider>()
.loadMessagesForUser(widget.otherUser.userId.toInt());
if (!context.mounted) return;
final allMessages = context
.read<MessagesChangeProvider>()
.allMessagesFromUser[widget.otherUser.userId.toInt()];
if (allMessages == null) {
return;
}
try {
final LocalAuthentication auth = LocalAuthentication();
bool didAuthenticate = await auth.authenticate(
localizedReason: 'Please authenticate to see this twonly!',
options: const AuthenticationOptions(useErrorDialogs: false));
if (!didAuthenticate) {
final nextMediaFiles = allMessages.where((x) =>
x.isMedia() &&
x.messageOtherId != null &&
x.messageOpenedAt == null &&
x.messageId != widget.message.messageId);
allMediaFiles.addAll(nextMediaFiles.map((x) => x));
setState(() {});
}
Future nextMediaOrExit() async {
nextMediaTimer?.cancel();
progressTimer?.cancel();
if (allMediaFiles.isEmpty || allMediaFiles.length == 1) {
if (context.mounted) {
Navigator.pop(context);
}
return;
}
} on PlatformException catch (e) {
debugPrint(e.toString());
// these errors because of hardware not available or bio is not enrolled
// as this is just a nice gimig, do not interrupt the user experience
} else {
allMediaFiles.removeAt(0);
loadCurrentMediaFile();
}
}
flutterLocalNotificationsPlugin.cancel(widget.message.messageId);
List<int> token = content.downloadToken;
_imageByte = await getDownloadedMedia(
token, widget.message.messageOtherId!, widget.message.otherUserId);
if (_imageByte == null) {
// image already deleted
if (context.mounted) {
Navigator.pop(context);
}
return;
}
// image loading does require some time
Future.delayed(Duration(milliseconds: 200), () {
Future loadCurrentMediaFile({bool showTwonly = false}) async {
await _noScreenshot.screenshotOff();
if (!context.mounted || allMediaFiles.isEmpty) return;
final DbMessage current = allMediaFiles.first;
setState(() {
mediaOpened();
// reset current image values
imageBytes = null;
canBeSeenUntil = null;
maxShowTime = 999999;
progress = 0;
isDownloading = false;
isRealTwonly = current.isRealTwonly();
});
// This will show the extra screen for the twonly
if (current.isRealTwonly() && !showTwonly) {
return;
}
final content = current.messageContent;
if (content is MediaMessageContent) {
if (isRealTwonly) {
bool isAuth = await authenticateUser(context.lang.mediaViewerAuthReason,
force: false);
if (!isAuth) {
nextMediaOrExit();
return;
}
}
flutterLocalNotificationsPlugin.cancel(current.messageId);
if (!current.isDownloaded) {
setState(() {
isDownloading = true;
});
await tryDownloadMedia(
current.messageId, current.otherUserId, content.downloadToken,
force: true);
}
do {
if (isDownloading) {
await Future.delayed(Duration(milliseconds: 100));
}
imageBytes = await getDownloadedMedia(
content.downloadToken,
current.messageOtherId!,
current.otherUserId,
);
} while (isDownloading && imageBytes == null);
isDownloading = false;
if (imageBytes == null) {
nextMediaOrExit();
return;
}
if (content.maxShowTime != 999999) {
canBeSeenUntil = DateTime.now().add(
Duration(seconds: content.maxShowTime),
);
maxShowTime = content.maxShowTime;
startTimer();
}
setState(() {});
}
}
startTimer() {
_timer = Timer(canBeSeenUntil!.difference(DateTime.now()), () {
nextMediaTimer?.cancel();
progressTimer?.cancel();
nextMediaTimer = Timer(canBeSeenUntil!.difference(DateTime.now()), () {
if (context.mounted) {
Navigator.pop(context);
nextMediaOrExit();
}
});
_timer2 = Timer.periodic(Duration(milliseconds: 10), (timer) {
progressTimer = Timer.periodic(Duration(milliseconds: 10), (timer) {
if (canBeSeenUntil != null) {
Duration difference = canBeSeenUntil!.difference(DateTime.now());
// Calculate the progress as a value between 0.0 and 1.0
@ -112,26 +175,11 @@ class _MediaViewerViewState extends State<MediaViewerView> {
});
}
mediaOpened() {
if (canBeSeenUntil != null) return;
final content = widget.message.messageContent;
if (content is MediaMessageContent) {
if (content.maxShowTime != 999999) {
canBeSeenUntil = DateTime.now().add(
Duration(seconds: content.maxShowTime),
);
maxShowTime = content.maxShowTime;
startTimer();
setState(() {});
}
}
}
@override
void dispose() {
super.dispose();
_timer?.cancel();
_timer2?.cancel();
nextMediaTimer?.cancel();
progressTimer?.cancel();
_noScreenshot.screenshotOn();
}
@ -142,10 +190,14 @@ class _MediaViewerViewState extends State<MediaViewerView> {
child: Stack(
fit: StackFit.expand,
children: [
if (_imageByte != null && (canBeSeenUntil == null || progress >= 0))
MediaViewSizing(
if (imageBytes != null && (canBeSeenUntil == null || progress >= 0))
GestureDetector(
onTap: () {
nextMediaOrExit();
},
child: MediaViewSizing(
Image.memory(
_imageByte!,
imageBytes!,
fit: BoxFit.contain,
frameBuilder:
((context, child, frame, wasSynchronouslyLoaded) {
@ -157,17 +209,19 @@ class _MediaViewerViewState extends State<MediaViewerView> {
: SizedBox(
height: 60,
width: 60,
child: CircularProgressIndicator(strokeWidth: 6),
child:
CircularProgressIndicator(strokeWidth: 6),
),
);
}),
),
),
if (isRealTwonly && _imageByte == null)
),
if (isRealTwonly && imageBytes == null)
Positioned.fill(
child: GestureDetector(
onTap: () {
loadMedia(force: true);
loadCurrentMediaFile(showTwonly: true);
},
child: Column(
children: [
@ -188,7 +242,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
left: 10,
top: 10,
child: Row(
// mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.close, size: 30),
@ -200,6 +253,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
],
),
),
if (isDownloading)
Positioned.fill(
child: Center(
child: SizedBox(
height: 60,
width: 60,
child: CircularProgressIndicator(strokeWidth: 6),
),
),
),
Positioned(
right: 20,
top: 27,
@ -217,7 +280,67 @@ class _MediaViewerViewState extends State<MediaViewerView> {
],
),
),
if (_imageByte != null)
AnimatedPositioned(
duration: Duration(milliseconds: 200), // Animation duration
bottom: showShortReactions ? 130 : 90,
left: showShortReactions ? 0 : 150,
right: showShortReactions ? 0 : 150,
curve: Curves.linearToEaseOut,
child: AnimatedOpacity(
opacity: showShortReactions ? 1.0 : 0.0, // Fade in/out
duration: Duration(milliseconds: 150),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(
6,
(index) {
final emoji =
EmojiAnimation.animatedIcons.keys.toList()[index];
return AnimatedSize(
duration:
Duration(milliseconds: 200), // Animation duration
curve: Curves.linearToEaseOut,
child: GestureDetector(
onTap: () {
sendTextMessage(widget.otherUser.userId, emoji);
setState(() {
selectedShortReaction = index;
});
Future.delayed(Duration(milliseconds: 300), () {
setState(() {
showShortReactions = false;
});
});
},
child: (selectedShortReaction == index)
? EmojiAnimationFlying(
emoji: emoji,
duration: Duration(milliseconds: 300),
startPosition: 0.0,
size: (showShortReactions) ? 40 : 10)
: AnimatedOpacity(
opacity: (selectedShortReaction == -1)
? 1
: 0, // Fade in/out
duration: Duration(milliseconds: 150),
child: SizedBox(
width: showShortReactions ? 40 : 10,
child: Center(
child: EmojiAnimation(
emoji: emoji,
),
),
),
),
),
);
},
),
),
),
),
if (imageBytes != null)
Positioned(
bottom: 30,
left: 0,
@ -225,9 +348,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// const SizedBox(width: 20),
FilledButton.icon(
icon: FaIcon(FontAwesomeIcons.solidPaperPlane),
IconButton.outlined(
icon: FaIcon(FontAwesomeIcons.camera),
onPressed: () async {
context.read<SendNextMediaTo>().updateSendNextMediaTo(
widget.otherUser.userId.toInt());
@ -239,9 +361,63 @@ class _MediaViewerViewState extends State<MediaViewerView> {
EdgeInsets.symmetric(vertical: 10, horizontal: 30),
),
),
label: Text(
"Respond",
style: TextStyle(fontSize: 17),
),
SizedBox(width: 10),
IconButton(
icon: SizedBox(
width: 40,
height: 40,
child: GridView.count(
crossAxisCount: 2,
children: List.generate(
4,
(index) {
return SizedBox(
width: 8,
height: 8,
child: Center(
child: EmojiAnimation(
emoji: EmojiAnimation.animatedIcons.keys
.toList()[index],
),
),
);
},
),
),
),
onPressed: () async {
setState(() {
showShortReactions = !showShortReactions;
selectedShortReaction = -1;
});
// context.read<SendNextMediaTo>().updateSendNextMediaTo(
// widget.otherUser.userId.toInt());
// globalUpdateOfHomeViewPageIndex(0);
// Navigator.popUntil(context, (route) => route.isFirst);
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
EdgeInsets.symmetric(vertical: 10, horizontal: 30),
),
),
),
SizedBox(width: 10),
IconButton.outlined(
icon: FaIcon(FontAwesomeIcons.message),
onPressed: () async {
Navigator.popUntil(context, (route) => route.isFirst);
Navigator.push(
context,
MaterialPageRoute(builder: (context) {
return ChatItemDetailsView(user: widget.otherUser);
}),
);
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
EdgeInsets.symmetric(vertical: 10, horizontal: 30),
),
),
),
],

View file

@ -85,4 +85,5 @@ flutter:
- assets/images/
- assets/images/onboarding/ricky_the_greedy_racoon.png
- assets/animated_icons/
- assets/animations/