feat(push): FCM via MAX server — oneme flavor, dedicated FCM workflow, opcode 22, notifications + CI/lint fixes

This commit is contained in:
klockky
2026-05-15 18:14:22 +03:00
parent 0a9780a024
commit 75ccc2e694
20 changed files with 647 additions and 203 deletions
+91
View File
@@ -0,0 +1,91 @@
name: Build Android (FCM)
on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- 'lib/**'
- 'android/**'
- 'pubspec.yaml'
- '.github/workflows/build-android-fcm.yml'
pull_request:
paths:
- 'lib/**'
- 'android/**'
- 'pubspec.yaml'
- '.github/workflows/build-android-fcm.yml'
jobs:
build-android-fcm:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.5'
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Configure Gradle
run: |
mkdir -p ~/.gradle
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
- name: Build Universal APK
run: flutter build apk --release --flavor oneme
- name: Build Split APKs
run: flutter build apk --release --split-per-abi --flavor oneme
- name: Upload Universal APK
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-universal
path: build/app/outputs/flutter-apk/app-oneme-release.apk
retention-days: 30
- name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-arm64-v8a
path: build/app/outputs/flutter-apk/app-oneme-arm64-v8a-release.apk
retention-days: 30
- name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-armeabi-v7a
path: build/app/outputs/flutter-apk/app-oneme-armeabi-v7a-release.apk
retention-days: 30
- name: Upload x86_64 APK
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-x86_64
path: build/app/outputs/flutter-apk/app-oneme-x86_64-release.apk
retention-days: 30
- name: Build App Bundle
run: flutter build appbundle --release --flavor oneme
- name: Upload App Bundle artifact
uses: actions/upload-artifact@v4
with:
name: komet-android-fcm-aab
path: build/app/outputs/bundle/onemeRelease/app-oneme-release.aab
retention-days: 30
+9 -9
View File
@@ -45,47 +45,47 @@ jobs:
mkdir -p ~/.gradle
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
- name: Build Universal APK
run: flutter build apk --release
run: flutter build apk --release --flavor komet
- name: Build Split APKs
run: flutter build apk --release --split-per-abi
run: flutter build apk --release --split-per-abi --flavor komet
- name: Upload Universal APK
uses: actions/upload-artifact@v4
with:
name: komet-android-universal
path: build/app/outputs/flutter-apk/app-release.apk
path: build/app/outputs/flutter-apk/app-komet-release.apk
retention-days: 30
- name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4
with:
name: komet-android-arm64-v8a
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
path: build/app/outputs/flutter-apk/app-komet-arm64-v8a-release.apk
retention-days: 30
- name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4
with:
name: komet-android-armeabi-v7a
path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
path: build/app/outputs/flutter-apk/app-komet-armeabi-v7a-release.apk
retention-days: 30
- name: Upload x86_64 APK
uses: actions/upload-artifact@v4
with:
name: komet-android-x86_64
path: build/app/outputs/flutter-apk/app-x86_64-release.apk
path: build/app/outputs/flutter-apk/app-komet-x86_64-release.apk
retention-days: 30
- name: Build App Bundle
run: flutter build appbundle --release
run: flutter build appbundle --release --flavor komet
- name: Upload App Bundle artifact
uses: actions/upload-artifact@v4
with:
name: komet-android-aab
path: build/app/outputs/bundle/release/app-release.aab
path: build/app/outputs/bundle/kometRelease/app-komet-release.aab
retention-days: 30
+15 -2
View File
@@ -13,10 +13,17 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 'stable'
flutter-version: '3.41.5'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
@@ -24,5 +31,11 @@ jobs:
- name: Flutter analyze
run: flutter analyze
- name: Configure Gradle
run: |
mkdir -p ~/.gradle
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
- name: Build Android APK
run: flutter build apk --release
run: flutter build apk --release --flavor komet
+69 -9
View File
@@ -6,17 +6,23 @@ on:
- 'main'
jobs:
analyze-and-build-all:
android:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 'stable'
flutter-version: '3.41.5'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
@@ -24,8 +30,34 @@ jobs:
- name: Flutter analyze
run: flutter analyze
- name: Configure Gradle
run: |
mkdir -p ~/.gradle
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
- name: Build Android APK
run: flutter build apk --release
run: flutter build apk --release --flavor komet
web-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.5'
channel: 'stable'
- name: Install Linux desktop dependencies
run: |
sudo apt-get update
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
- name: Install dependencies
run: flutter pub get
- name: Build Web
run: flutter build web --release
@@ -33,13 +65,41 @@ jobs:
- name: Build Linux
run: flutter build linux --release
windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.5'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Build Windows
run: flutter build windows --release
- name: Build iOS (only macOS runners)
if: runner.os == 'macOS'
run: flutter build ios --release
apple:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Build macOS (only macOS runners)
if: runner.os == 'macOS'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.5'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Build iOS
run: flutter build ios --release --no-codesign
- name: Build macOS
run: flutter build macos --release
+16 -4
View File
@@ -3,6 +3,7 @@ plugins {
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
}
android {
@@ -20,16 +21,27 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "ru.komet.app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = maxOf(flutter.minSdkVersion, 23)
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
flavorDimensions += "distribution"
productFlavors {
create("komet") {
dimension = "distribution"
isDefault = true
applicationId = "ru.komet.app"
}
create("oneme") {
dimension = "distribution"
applicationId = "ru.oneme.app"
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "659634599081",
"project_id": "max-messenger-app",
"storage_bucket": "max-messenger-app.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:659634599081:android:00000000000000000000ab",
"android_client_info": {
"package_name": "ru.komet.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyABuDYeeDXIOrKTXLkUj30Ii143ofPe63Q"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+4
View File
@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="komet"
android:name="${applicationName}"
@@ -33,6 +34,9 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="komet_messages" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "659634599081",
"project_id": "max-messenger-app",
"storage_bucket": "max-messenger-app.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:659634599081:android:9605285443b661167225b8",
"android_client_info": {
"package_name": "ru.oneme.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyABuDYeeDXIOrKTXLkUj30Ii143ofPe63Q"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+1
View File
@@ -21,6 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("com.google.gms.google-services") version "4.4.3" apply false
}
include(":app")
+33
View File
@@ -210,6 +210,12 @@ enum AuthRequestType {
enum LoginStatus { idle, loading, success, error }
class WrongDeviceTokenException implements Exception {
const WrongDeviceTokenException();
@override
String toString() => 'WrongDeviceTokenException';
}
class RequestCodeResult {
final String token;
@@ -429,6 +435,33 @@ class AccountModule {
return config;
}
Future<void> registerPushToken(String pushToken) async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.config, <dynamic, dynamic>{
'pushToken': pushToken,
});
if (packet.isError) {
final msg = messageFromErrorPayload(packet.payload).toUpperCase();
if (msg.contains('WRONG_DEVICE_TOKEN') ||
msg.contains('WRONG.DEVICE.TOKEN')) {
throw const WrongDeviceTokenException();
}
throw PacketError(messageFromErrorPayload(packet.payload));
}
}
Future<void> unregisterPushToken(String pushToken) async {
if (_api.state != SessionState.online) return;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final authToken = await TokenStorage.readToken(accountId);
if (authToken == null) return;
await _api.sendRequest(Opcode.logout, <dynamic, dynamic>{
'token': authToken,
'pushToken': pushToken,
});
}
Future<ProfileData> updateProfileName(String firstName, String? lastName) async {
_ensureOnline();
final payload = <dynamic, dynamic>{
+188
View File
@@ -0,0 +1,188 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../backend/api.dart';
import '../../backend/modules/account.dart';
import '../utils/logger.dart';
const _channelId = 'komet_messages';
const _channelName = 'Сообщения';
const _prefsTokenKey = 'fcm_push_token';
@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
if (message.notification != null) return;
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _display(plugin, message);
}
Future<void> _display(
FlutterLocalNotificationsPlugin plugin,
RemoteMessage message,
) async {
final data = message.data;
final title = message.notification?.title ??
data['title']?.toString() ??
data['sender']?.toString() ??
'MAX';
final body = message.notification?.body ??
data['body']?.toString() ??
data['text']?.toString() ??
data['message']?.toString() ??
'Новое сообщение';
await plugin.show(
id: message.messageId?.hashCode ??
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: title,
body: body,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
),
),
);
}
class PushService {
PushService._();
static final PushService instance = PushService._();
final FlutterLocalNotificationsPlugin _local =
FlutterLocalNotificationsPlugin();
Api? _api;
AccountModule? _account;
String? _token;
bool _initialized = false;
Future<void> init({required Api api, required AccountModule account}) async {
if (_initialized) return;
_api = api;
_account = account;
try {
await Firebase.initializeApp();
} catch (e) {
logger.w('Push: Firebase init не удался: $e');
return;
}
_initialized = true;
await _local.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _local
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(
const AndroidNotificationChannel(
_channelId,
_channelName,
importance: Importance.high,
),
);
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();
FirebaseMessaging.onBackgroundMessage(_backgroundHandler);
FirebaseMessaging.onMessage.listen((m) {
_display(_local, m);
});
messaging.onTokenRefresh.listen((t) async {
_token = t;
await _persistToken(t);
await _registerWithServer();
});
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString(_prefsTokenKey);
try {
_token = await messaging.getToken() ?? _token;
if (_token != null) await _persistToken(_token!);
logger.i('Push: FCM-токен получен (${_token?.length ?? 0} симв.)');
} catch (e) {
logger.w('Push: getToken не удался: $e');
}
}
Future<void> onLoginSuccess() async {
if (!_initialized) return;
if (_token == null) {
try {
_token = await FirebaseMessaging.instance.getToken();
if (_token != null) await _persistToken(_token!);
} catch (_) {}
}
await _registerWithServer();
}
Future<void> unregister() async {
if (!_initialized || _token == null) return;
final account = _account;
if (account != null) {
try {
await account.unregisterPushToken(_token!);
} catch (e) {
logger.w('Push: unregister не удался: $e');
}
}
try {
await FirebaseMessaging.instance.deleteToken();
} catch (_) {}
_token = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsTokenKey);
}
Future<void> _registerWithServer() async {
final account = _account;
final api = _api;
if (account == null || api == null) return;
if (api.state != SessionState.online) return;
final token = _token;
if (token == null || token.isEmpty) return;
try {
await account.registerPushToken(token);
logger.i('Push: токен зарегистрирован на сервере MAX');
} on WrongDeviceTokenException {
logger.w('Push: WRONG_DEVICE_TOKEN, переполучаю токен');
try {
await FirebaseMessaging.instance.deleteToken();
final fresh = await FirebaseMessaging.instance.getToken();
if (fresh != null && fresh.isNotEmpty) {
_token = fresh;
await _persistToken(fresh);
await account.registerPushToken(fresh);
logger.i('Push: токен перерегистрирован');
}
} catch (e) {
logger.w('Push: повторная регистрация не удалась: $e');
}
} catch (e) {
logger.w('Push: регистрация токена не удалась: $e');
}
}
Future<void> _persistToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsTokenKey, token);
}
}
@@ -1085,7 +1085,7 @@ class _ChatListScreenState extends State<ChatListScreen>
child: Text(
'Кажется, тут пусто...',
style: TextStyle(
color: cs.onSurface.withOpacity(0.6),
color: cs.onSurface.withValues(alpha: 0.6),
fontSize: 16,
),
),
@@ -1146,10 +1146,6 @@ class _ChatListScreenState extends State<ChatListScreen>
? ContactCache.get(chat.lastMsgSenderId!)
: null;
final avatar = chat.lastMsgSenderId != null
? ContactCache.getAvatar(chat.lastMsgSenderId!)
: null;
String fullMsg = "";
if (name?.isNotEmpty == true && chat.id != 0) {
@@ -2073,7 +2069,6 @@ Navigator.push(
}
Widget _buildFabMenu() {
final cs = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
@@ -11,7 +11,6 @@ import '../../../backend/modules/messages.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/haptics.dart';
import '../../../models/attachment.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../widgets/message_bubble.dart';
import '../../widgets/attachment_panel.dart';
@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/app_database.dart';
@@ -63,6 +62,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
);
_avatarUrl = newProfile.baseUrl;
_photoId = newProfile.photoId;
if (!mounted) return;
KometApp.stateOf(context)?.notifyProfileUpdate();
if (mounted) {
showCustomNotification(context, 'Имя сохранено');
@@ -93,6 +93,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
final newProfile = await accountModule.removeProfilePhoto(_photoId!);
_avatarUrl = newProfile.baseUrl;
_photoId = newProfile.photoId;
if (!mounted) return;
KometApp.stateOf(context)?.notifyProfileUpdate();
if (mounted) {
showCustomNotification(context, 'Фото удалено');
@@ -15,8 +15,6 @@ class PasswordEntryScreen extends StatefulWidget {
class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
bool _isLoading = true;
bool _is2faEnabled = false;
String? _email;
String? _hint;
@override
void initState() {
@@ -943,7 +943,7 @@ class _SecurityScreenState extends State<SecurityScreen>
fontSize: 14,
),
),
if (trailing != null) trailing,
?trailing,
const SizedBox(width: 4),
Icon(
Symbols.chevron_right,
+14 -164
View File
@@ -1,9 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/backend/modules/contacts.dart';
import 'package:komet/main.dart';
import 'package:flutter/foundation.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart';
import '../../core/utils/haptics.dart';
@@ -23,16 +20,18 @@ class MessageBubble extends StatelessWidget {
static const double compactTimePadding = 8.0;
bool get _hasPhotoWithCaption {
if (message.attachments == null || message.attachments!.isEmpty)
if (message.attachments == null || message.attachments!.isEmpty) {
return false;
}
final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment);
final hasCaption = message.text != null && message.text!.isNotEmpty;
return hasPhoto && hasCaption;
}
bool get _hasMultiplePhotosNoCaption {
if (message.attachments == null || message.attachments!.isEmpty)
if (message.attachments == null || message.attachments!.isEmpty) {
return false;
}
final photoCount = message.attachments!.whereType<PhotoAttachment>().length;
final hasCaption = message.text != null && message.text!.isNotEmpty;
return photoCount >= 2 && !hasCaption;
@@ -226,7 +225,6 @@ class MessageBubble extends StatelessWidget {
case MessageType.control:
return 4;
}
return 4;
}
double get bottomMargin {
@@ -253,17 +251,6 @@ class MessageBubble extends StatelessWidget {
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.attachment:
switch (shape) {
case BubbleShape.singleTop:
return 1;
case BubbleShape.singleBottom:
return 1;
case BubbleShape.singleMiddle:
return 4;
case BubbleShape.groupedMiddle:
return 1;
}
case MessageType.voice:
switch (shape) {
case BubbleShape.singleTop:
@@ -278,7 +265,6 @@ class MessageBubble extends StatelessWidget {
case MessageType.control:
return 4;
}
return 4;
}
// Внутренний отступ, размеры типа я хз
@@ -311,7 +297,6 @@ class MessageBubble extends StatelessWidget {
case MessageType.control:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
}
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
}
@override
@@ -491,7 +476,7 @@ class MessageBubble extends StatelessWidget {
children: [
Flexible(
child: isForwarded
? _buildForwardedInlineText(context, forwarded!, textColor)
? _buildForwardedInlineText(context, forwarded, textColor)
: Text(
message.text ?? '',
style: TextStyle(color: textColor, fontSize: 16, height: 1.3),
@@ -590,67 +575,15 @@ class MessageBubble extends StatelessWidget {
}
ForwardedMessageAttachment? _getForwardedAttachment() {
if (message.attachments == null || message.attachments!.isEmpty)
if (message.attachments == null || message.attachments!.isEmpty) {
return null;
}
for (final a in message.attachments!) {
if (a is ForwardedMessageAttachment) return a;
}
return null;
}
Widget _buildForwardedHeader(
BuildContext context,
ForwardedMessageAttachment forwarded,
) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93));
final senderName = forwarded.originalSenderName;
final displaySender = senderName ?? forwarded.originalSenderId.toString();
final origText = forwarded.originalText;
final hasOrigText = origText != null && origText.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.forward, size: 14, color: headerColor),
const SizedBox(width: 4),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
if (hasOrigText) ...[
const SizedBox(height: 2),
Text(
origText,
style: TextStyle(color: textColor, fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
);
}
Widget _buildAttachmentContent(BuildContext context) {
final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) {
@@ -843,65 +776,6 @@ class MessageBubble extends StatelessWidget {
);
}
Widget _buildForwardedFileContent(
BuildContext context,
ForwardedMessageAttachment forwarded,
List<FileAttachment> files,
) {
final cs = Theme.of(context).colorScheme;
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
: cs.onSurfaceVariant;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderId.toString();
final senderAvatar = forwarded.originalSenderAvatar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.forward, size: 14, color: headerColor),
const SizedBox(width: 4),
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
else
CircleAvatar(
radius: 10,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
),
),
const SizedBox(width: 6),
Text(
displaySender,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 4),
...files.map((file) => _buildFileAttachment(context, file)),
],
);
}
Widget _buildForwardedGenericContent(
BuildContext context,
ForwardedMessageAttachment forwarded,
@@ -1001,7 +875,7 @@ class MessageBubble extends StatelessWidget {
height: constrainedHeight,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => _buildPhotoPlaceholder(
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
ctx,
constrainedWidth,
constrainedHeight,
@@ -1106,7 +980,7 @@ class MessageBubble extends StatelessWidget {
width: double.infinity,
height: double.infinity,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx, 100, 100),
)
else
@@ -1141,7 +1015,7 @@ class MessageBubble extends StatelessWidget {
width: double.infinity,
height: double.infinity,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx, 100, 100),
)
else
@@ -1209,12 +1083,6 @@ class MessageBubble extends StatelessWidget {
BuildContext ctx,
MessageAttachment attachment,
) {
final cs = Theme.of(ctx).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textColor = isMe
? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
switch (attachment.type) {
case AttachmentType.video:
return _buildVideoAttachment(ctx, attachment);
@@ -1371,7 +1239,7 @@ class MessageBubble extends StatelessWidget {
height: 150,
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx, 150, 150),
)
else
@@ -1427,7 +1295,7 @@ class MessageBubble extends StatelessWidget {
imageUrl: photoUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => Icon(
errorWidget: (_, _, _) => Icon(
Symbols.person,
color: isMe ? Colors.white : cs.primary,
size: 24,
@@ -1573,7 +1441,7 @@ class MessageBubble extends StatelessWidget {
imageUrl: photoUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => Icon(
errorWidget: (_, _, _) => Icon(
Symbols.person,
color: isMe ? Colors.white : cs.primary,
size: 24,
@@ -1680,7 +1548,6 @@ class MessageBubble extends StatelessWidget {
Widget _buildMeta(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final timeColor = isMe ? Colors.white70 : cs.onSurfaceVariant;
return Padding(
@@ -1700,8 +1567,6 @@ class MessageBubble extends StatelessWidget {
}
Widget _buildCompactTime(BuildContext ctx) {
final cs = Theme.of(ctx).colorScheme;
final isDark = cs.brightness == Brightness.dark;
final bgColor = isMe
? Colors.black.withValues(alpha: 0.4)
: Colors.black.withValues(alpha: 0.5);
@@ -1761,12 +1626,6 @@ class MessageBubble extends StatelessWidget {
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String _formatDuration(int seconds) {
final min = seconds ~/ 60;
final sec = seconds % 60;
return '$min:${sec.toString().padLeft(2, '0')}';
}
}
class _VoiceMessageBubble extends StatefulWidget {
@@ -1866,6 +1725,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
return Icon(icon, size: 14, color: color);
}
@override
Widget build(BuildContext context) {
final isDark = widget.cs.brightness == Brightness.dark;
final waveInactiveColor = widget.isMe
@@ -2042,16 +1902,6 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
);
}
Widget _buildProgressBar(Color inactive, Color active) {
return Container(
height: 4,
decoration: BoxDecoration(
color: inactive,
borderRadius: BorderRadius.circular(2),
),
);
}
void _togglePlay() {
setState(() {
_isPlaying = !_isPlaying;
+18
View File
@@ -4,11 +4,13 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'backend/api.dart';
import 'backend/modules/account.dart';
import 'backend/modules/contacts.dart';
import 'backend/modules/messages.dart';
import 'core/push/push_service.dart';
import 'core/storage/app_database.dart';
import 'core/storage/token_storage.dart';
import 'core/utils/haptics.dart';
@@ -43,6 +45,12 @@ void main() async {
await ContactsModule.primeCacheFromDb(activeAccountId);
}
await api.connect();
final packageInfo = await PackageInfo.fromPlatform();
if (packageInfo.packageName == 'ru.oneme.app') {
await PushService.instance.init(api: api, account: accountModule);
}
final initialLocale = await _loadInitialLocale();
await Haptics.load();
@@ -82,6 +90,7 @@ class KometAppState extends State<KometApp> {
late Locale _locale;
bool _isLoggingOut = false;
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
StreamSubscription<LoginStatus>? _loginStatusSub;
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
widget.initialFpsOverlay,
);
@@ -105,10 +114,18 @@ class KometAppState extends State<KometApp> {
} catch (_) {}
});
_loginStatusSub = accountModule.loginStatusStream.listen((status) {
if (status == LoginStatus.success) {
PushService.instance.onLoginSuccess();
}
});
_sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async {
if (_isLoggingOut) return;
_isLoggingOut = true;
await PushService.instance.unregister();
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await accountModule.removeAccount(accountId);
@@ -133,6 +150,7 @@ class KometAppState extends State<KometApp> {
@override
void dispose() {
_sessionExpiredSub?.cancel();
_loginStatusSub?.cancel();
_profileUpdateController.close();
fpsOverlayEnabled.dispose();
super.dispose();
+124 -4
View File
@@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "8f89e371e2883de35cdc78f648e725fa4da5f3b6c927269f00fa68f1ea92b598"
url: "https://pub.dev"
source: hosted
version: "1.3.71"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -105,6 +121,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
device_info_plus:
dependency: "direct main"
description:
@@ -169,6 +193,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.3.7"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "93a5bde9775fd5adcc937f39dfa04ae0bc89c4d79bea6abc49de3f7b049d9ff6"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: "7c98f10b8c8e5adedc0b810b66a877120696675e2c22d9ca9caca092da0d9e57"
url: "https://pub.dev"
source: hosted
version: "3.7.0"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "8d0dc81a31cd030170508dc3e89bfd14355b20a1b991340af5f018e37daab5d7"
url: "https://pub.dev"
source: hosted
version: "16.2.2"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "37abb0b0535c5497605ee94c12470e1ebbbe47e71a22d0c20bffcc912311f8cb"
url: "https://pub.dev"
source: hosted
version: "4.7.11"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "54e22b43e2c26a2728a3f68c188de0f9011993ae19ae959a06d476dad935c776"
url: "https://pub.dev"
source: hosted
version: "4.1.7"
fixnum:
dependency: transitive
description:
@@ -198,6 +270,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
url: "https://pub.dev"
source: hosted
version: "21.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
url: "https://pub.dev"
source: hosted
version: "11.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
@@ -385,10 +489,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.18"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
@@ -525,6 +629,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -726,10 +838,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.9"
version: "0.7.10"
timezone:
dependency: "direct main"
description:
@@ -802,6 +914,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
+3
View File
@@ -58,6 +58,9 @@ dependencies:
package_info_plus: ^9.0.1
mobile_scanner: ^7.2.0
cached_network_image: ^3.4.1
firebase_core: ^4.1.1
firebase_messaging: ^16.0.2
flutter_local_notifications: ^21.0.0
dev_dependencies:
flutter_test: