diff --git a/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt b/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt index 1a2c719..13a1ac5 100644 --- a/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt +++ b/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt @@ -8,7 +8,9 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person +import androidx.core.app.ServiceCompat object CallState { @Volatile @@ -27,6 +29,9 @@ class CallForegroundService : Service() { @Volatile var screenShare = false + @Volatile + private var running: CallForegroundService? = null + fun setScreenShare(ctx: Context, enabled: Boolean, caller: String) { screenShare = enabled val intent = Intent(ctx, CallForegroundService::class.java).apply { @@ -65,6 +70,12 @@ class CallForegroundService : Service() { fun stop(ctx: Context) { CallState.inCall = false screenShare = false + val service = running + if (service != null) { + service.shutdown() + return + } + NotificationManagerCompat.from(ctx).cancel(ONGOING_ID) try { ctx.startService( Intent(ctx, CallForegroundService::class.java).apply { @@ -78,18 +89,25 @@ class CallForegroundService : Service() { override fun onBind(intent: Intent?): IBinder? = null + override fun onCreate() { + super.onCreate() + running = this + } + override fun onDestroy() { + if (running === this) running = null CallState.inCall = false super.onDestroy() } + private fun shutdown() { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { - ACTION_STOP -> { - @Suppress("DEPRECATION") - stopForeground(true) - stopSelf() - } + ACTION_STOP -> shutdown() ACTION_SCREEN_SHARE -> { screenShare = intent.getBooleanExtra(EXTRA_SCREEN_SHARE, false) val caller = intent.getStringExtra(CallConst.EXTRA_CALLER) ?: "Звонок" diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index ad71039..d05cd8c 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -83,6 +83,9 @@ class MainActivity : FlutterActivity() { private val shareHandler = Handler(Looper.getMainLooper()) private companion object { + @Volatile + var keepAwake = false + const val LOG_TAG = "VpnBypass" const val SHARE_TAG = "ShareIntake" const val NFC_TAG = "NfcExchange" @@ -359,6 +362,10 @@ class MainActivity : FlutterActivity() { ) result.success(null) } + "dropOngoing" -> { + CallForegroundService.stop(applicationContext) + result.success(null) + } "notifyEnded" -> { CallRinger.stop() NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) @@ -395,6 +402,19 @@ class MainActivity : FlutterActivity() { } } + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/screen", + ).setMethodCallHandler { call, result -> + when (call.method) { + "setKeepAwake" -> { + setKeepAwake(call.argument("enabled") == true) + result.success(null) + } + else -> result.notImplemented() + } + } + EventChannel( flutterEngine.dartExecutor.binaryMessenger, "ru.komet.app/calls_events", @@ -498,6 +518,7 @@ class MainActivity : FlutterActivity() { override fun onCreate(savedInstanceState: Bundle?) { if (intent?.hasExtra(CallConst.EXTRA_CALL) == true) applyCallWindowFlags() super.onCreate(savedInstanceState) + applyKeepAwake() intent?.let { if (it.hasExtra(CallConst.EXTRA_CALL)) stashCall(it, emit = false) } stashChatOpen(intent, emit = false) stashShare(intent, emit = false) @@ -557,6 +578,9 @@ class MainActivity : FlutterActivity() { private fun stashCall(intent: Intent, emit: Boolean) { val json = intent.getStringExtra(CallConst.EXTRA_CALL) ?: return val action = intent.getStringExtra(CallConst.EXTRA_ACTION) ?: CallConst.ACTION_RING + intent.removeExtra(CallConst.EXTRA_CALL) + intent.removeExtra(CallConst.EXTRA_ACTION) + intent.removeExtra(CallConst.EXTRA_CALLER) if (action == CallConst.ACTION_ANSWER) CallRinger.stop() val map = mapOf("data" to json, "action" to action) val sink = CallEvents.sink @@ -575,8 +599,7 @@ class MainActivity : FlutterActivity() { @Suppress("DEPRECATION") window.addFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, ) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -593,12 +616,24 @@ class MainActivity : FlutterActivity() { @Suppress("DEPRECATION") window.clearFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, ) } } + private fun setKeepAwake(enabled: Boolean) { + keepAwake = enabled + runOnUiThread { applyKeepAwake() } + } + + private fun applyKeepAwake() { + if (keepAwake) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + // Центр-кроп видео в квадрат size×size (без искажений) через media3 // Transformer: LAYOUT_SCALE_TO_FIT_WITH_CROP заполняет квадрат и обрезает // лишнее по бокам. diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 64183c3..942fbe6 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -42,6 +42,7 @@ final class KometStreamHandler: NSObject, FlutterStreamHandler { registerVideo(messenger) registerVideoNote(messenger) registerNotifications(messenger) + registerScreen(messenger) } return super.application(application, didFinishLaunchingWithOptions: launchOptions) @@ -150,6 +151,21 @@ final class KometStreamHandler: NSObject, FlutterStreamHandler { body(recorder) } + private func registerScreen(_ messenger: FlutterBinaryMessenger) { + method("ru.komet.app/screen", messenger) { call, result in + switch call.method { + case "setKeepAwake": + let enabled = ((call.arguments as? [String: Any])?["enabled"] as? NSNumber)?.boolValue ?? false + DispatchQueue.main.async { + UIApplication.shared.isIdleTimerDisabled = enabled + result(nil) + } + default: + result(FlutterMethodNotImplemented) + } + } + } + private func registerNotifications(_ messenger: FlutterBinaryMessenger) { method("ru.komet.app/notifications", messenger) { call, result in KometNotifications.shared.handle(call, result: result) diff --git a/lib/core/calls/call_bridge.dart b/lib/core/calls/call_bridge.dart index 3de35ab..30a55ba 100644 --- a/lib/core/calls/call_bridge.dart +++ b/lib/core/calls/call_bridge.dart @@ -99,6 +99,15 @@ class CallBridge { } } + Future dropOngoing() async { + if (!_android) return; + try { + await _method.invokeMethod('dropOngoing'); + } catch (e) { + logger.w('CallBridge.dropOngoing: $e'); + } + } + Future notifyEnded() async { if (!_android) return; try { diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index b6fc253..92d07da 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -57,6 +57,7 @@ class CallController { Stream get incomingCanceled => _canceled.stream; CallSession? _active; + StreamSubscription? _activeSub; CallSession? get activeSession => _active; IncomingCall? _pending; @@ -161,10 +162,7 @@ class CallController { osVersion: _api?.callsOsVersion, ); final session = CallSession(ws2Config: config, role: CallRole.caller); - _bind(session); - await session.start(); - CallBridge.instance.notifyAccepted(); - return session; + return _launch(session, session.start); } Future createConference() async { @@ -189,10 +187,7 @@ class CallController { role: CallRole.joiner, isGroup: true, ); - _bind(session); - await session.start(); - CallBridge.instance.notifyAccepted(); - return session; + return _launch(session, session.start); } Future acceptIncoming(IncomingCall call) async { @@ -209,11 +204,14 @@ class CallController { params: call.params, role: CallRole.callee, ); - _bind(session); - await session.start(); - await session.accept(); - CallBridge.instance.notifyAccepted(caller: call.callerName); - return session; + return _launch( + session, + () async { + await session.start(); + await session.accept(); + }, + caller: call.callerName, + ); } Future rejectIncoming(IncomingCall call) async { @@ -244,18 +242,45 @@ class CallController { return true; } + Future _launch( + CallSession session, + Future Function() open, { + String? caller, + }) async { + _bind(session); + try { + await open(); + } catch (_) { + await _release(session); + try { + await session.hangup(); + } catch (_) {} + rethrow; + } + CallBridge.instance.notifyAccepted(caller: caller); + return session; + } + void _bind(CallSession session) { + unawaited(_activeSub?.cancel()); _active = session; - session.stateStream.listen((state) { - if (state == CallSessionState.ended && _active == session) { - _active = null; - CallBridge.instance.notifyEnded(); - _ended.add(null); - } + _activeSub = session.stateStream.listen((state) { + if (state != CallSessionState.ended) return; + unawaited(_release(session)); }); } + Future _release(CallSession session) async { + if (!identical(_active, session)) return; + _active = null; + await _activeSub?.cancel(); + _activeSub = null; + CallBridge.instance.notifyEnded(); + _ended.add(null); + } + void dispose() { + _activeSub?.cancel(); _pushSub?.cancel(); _incoming.close(); _ended.close(); diff --git a/lib/core/media/gallery_source.dart b/lib/core/media/gallery_source.dart index 1bede28..f9ae88f 100644 --- a/lib/core/media/gallery_source.dart +++ b/lib/core/media/gallery_source.dart @@ -25,6 +25,15 @@ abstract class GalleryItem { static GalleryItem fromFile(File file) => _FileGalleryItem(file); } +class GalleryPage { + const GalleryPage({required this.items, required this.hasMore}); + + static const empty = GalleryPage(items: [], hasMore: false); + + final List items; + final bool hasMore; +} + class PickedPhoto { final GalleryItem item; final File? editedFile; @@ -49,8 +58,10 @@ Future<(int, int)?> imageFileDimensions(File file) async { } abstract class GallerySource { + static const int pageSize = 120; + Future ensurePermission(); - Future> load({int limit}); + Future load({int offset, int limit}); Future openSettings(); Future manageAccess(); @@ -71,20 +82,51 @@ class _PhotoManagerSource implements GallerySource { return GalleryPermission.denied; } - @override - Future> load({int limit = 120}) async { - final paths = await PhotoManager.getAssetPathList( - type: RequestType.common, - onlyAll: true, - filterOption: FilterOptionGroup( - orders: const [ - OrderOption(type: OrderOptionType.createDate, asc: false), - ], + AssetPathEntity? _album; + int _total = 0; + + static FilterOptionGroup _filter() => FilterOptionGroup( + imageOption: const FilterOption( + sizeConstraint: SizeConstraint(ignoreSize: true), + ), + videoOption: const FilterOption( + sizeConstraint: SizeConstraint(ignoreSize: true), + durationConstraint: DurationConstraint( + max: Duration(days: 365), + allowNullable: true, ), + ), + createTimeCond: DateTimeCond.def().copyWith(ignore: true), + orders: const [OrderOption(type: OrderOptionType.createDate, asc: false)], + ); + + @override + Future load({ + int offset = 0, + int limit = GallerySource.pageSize, + }) async { + if (offset == 0 || _album == null) { + final paths = await PhotoManager.getAssetPathList( + type: RequestType.common, + onlyAll: true, + filterOption: _filter(), + ); + if (paths.isEmpty) { + _album = null; + _total = 0; + return GalleryPage.empty; + } + _album = paths.first; + _total = await paths.first.assetCountAsync; + } + final album = _album; + if (album == null || offset >= _total) return GalleryPage.empty; + final end = offset + limit < _total ? offset + limit : _total; + final assets = await album.getAssetListRange(start: offset, end: end); + return GalleryPage( + items: assets.map((a) => _AssetGalleryItem(a)).toList(), + hasMore: end < _total, ); - if (paths.isEmpty) return const []; - final assets = await paths.first.getAssetListRange(start: 0, end: limit); - return assets.map((a) => _AssetGalleryItem(a)).toList(); } @override @@ -172,8 +214,34 @@ class _DesktopGallerySource implements GallerySource { Future ensurePermission() async => GalleryPermission.granted; + List<_FileGalleryItem> _all = const []; + @override - Future> load({int limit = 120}) async { + Future load({ + int offset = 0, + int limit = GallerySource.pageSize, + }) async { + if (offset == 0 || _all.isEmpty) _all = _scan(); + if (offset >= _all.length) return GalleryPage.empty; + final end = offset + limit < _all.length ? offset + limit : _all.length; + final items = _all.sublist(offset, end); + const batch = 8; + const eager = 24; + + Future probeRange(int from, int to) async { + for (var i = from; i < to; i += batch) { + final stop = i + batch > to ? to : i + batch; + await Future.wait(items.sublist(i, stop).map((it) => it.probe())); + } + } + + final head = items.length < eager ? items.length : eager; + await probeRange(0, head); + if (head < items.length) unawaited(probeRange(head, items.length)); + return GalleryPage(items: items, hasMore: end < _all.length); + } + + List<_FileGalleryItem> _scan() { final entries = <({File file, DateTime modified})>[]; for (final dir in _candidateDirs()) { if (!dir.existsSync()) continue; @@ -185,24 +253,7 @@ class _DesktopGallerySource implements GallerySource { } catch (_) {} } entries.sort((a, b) => b.modified.compareTo(a.modified)); - final items = entries - .take(limit) - .map((e) => _FileGalleryItem(e.file)) - .toList(); - const batch = 8; - const eager = 24; - - Future probeRange(int from, int to) async { - for (var i = from; i < to; i += batch) { - final end = i + batch > to ? to : i + batch; - await Future.wait(items.sublist(i, end).map((it) => it.probe())); - } - } - - final head = items.length < eager ? items.length : eager; - await probeRange(0, head); - if (head < items.length) unawaited(probeRange(head, items.length)); - return items; + return entries.map((e) => _FileGalleryItem(e.file)).toList(); } @override diff --git a/lib/core/utils/screen_wake.dart b/lib/core/utils/screen_wake.dart new file mode 100644 index 0000000..da88482 --- /dev/null +++ b/lib/core/utils/screen_wake.dart @@ -0,0 +1,41 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import 'logger.dart'; + +class ScreenWake { + ScreenWake._(); + + static final ScreenWake instance = ScreenWake._(); + + static const _channel = MethodChannel('ru.komet.app/screen'); + + final Set _holders = {}; + + bool get _supported { + try { + return Platform.isAndroid || Platform.isIOS; + } catch (_) { + return false; + } + } + + Future acquire(Object holder) async { + if (!_supported || !_holders.add(holder)) return; + if (_holders.length == 1) await _apply(true); + } + + Future release(Object holder) async { + if (!_supported || !_holders.remove(holder)) return; + if (_holders.isEmpty) await _apply(false); + } + + Future _apply(bool enabled) async { + try { + await _channel.invokeMethod('setKeepAwake', {'enabled': enabled}); + } catch (e) { + logger.w('ScreenWake._apply: enabled=$enabled $e'); + } + } +} diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index afea43c..6ee6274 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -17,6 +17,7 @@ import '../../../core/calls/call_session.dart'; import '../../../core/config/app_colors.dart'; import '../../../core/config/call_no_mute.dart'; import '../../../core/utils/format.dart'; +import '../../../core/utils/screen_wake.dart'; import '../../../l10n/app_localizations.dart'; import '../../widgets/call_video_view.dart'; import '../../widgets/custom_notification.dart'; @@ -133,6 +134,7 @@ class _CallScreenState extends State with TickerProviderStateMixin { void initState() { super.initState(); ActiveCall.instance.enterScreen(); + unawaited(ScreenWake.instance.acquire(this)); _dotsController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), @@ -422,6 +424,7 @@ class _CallScreenState extends State with TickerProviderStateMixin { @override void dispose() { ActiveCall.instance.leaveScreen(); + unawaited(ScreenWake.instance.release(this)); _stateSub?.cancel(); _canceledSub?.cancel(); _infoSub?.cancel(); diff --git a/lib/frontend/screens/chats/chat/video_note_controller.dart b/lib/frontend/screens/chats/chat/video_note_controller.dart index 2606004..f3deb64 100644 --- a/lib/frontend/screens/chats/chat/video_note_controller.dart +++ b/lib/frontend/screens/chats/chat/video_note_controller.dart @@ -16,6 +16,7 @@ import '../../../../core/config/app_video_note_quality.dart'; import '../../../../core/media/native_video_note_recorder.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/logger.dart'; +import '../../../../core/utils/screen_wake.dart'; import '../../../widgets/custom_notification.dart'; import '../../../widgets/lottie_slash_icon.dart'; import 'voice_record_controller.dart'; @@ -97,6 +98,7 @@ class VideoNoteController { unawaited(AssetLottie(_flashIcon).load()); if (_stub) { _camReady.value = true; + unawaited(ScreenWake.instance.acquire(this)); _textureId.value = null; return; } @@ -124,6 +126,7 @@ class VideoNoteController { } _textureId.value = _rec.textureId; _camReady.value = true; + unawaited(ScreenWake.instance.acquire(this)); } catch (e) { logger.w('initNoteCamera: $e'); await _disposeCamera(); @@ -156,6 +159,7 @@ class VideoNoteController { Future _disposeCamera() async { _camReady.value = false; + unawaited(ScreenWake.instance.release(this)); _textureId.value = null; if (!_stub) await _rec.dispose(); } @@ -304,6 +308,7 @@ class VideoNoteController { } void dispose() { + unawaited(ScreenWake.instance.release(this)); _timer?.cancel(); _rec.dispose(); _textureId.dispose(); diff --git a/lib/frontend/screens/chats/chat/voice_record_controller.dart b/lib/frontend/screens/chats/chat/voice_record_controller.dart index 493ae12..26fda70 100644 --- a/lib/frontend/screens/chats/chat/voice_record_controller.dart +++ b/lib/frontend/screens/chats/chat/voice_record_controller.dart @@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart'; import '../../../../core/media/opus_ogg_encoder.dart'; import '../../../../core/utils/haptics.dart'; +import '../../../../core/utils/screen_wake.dart'; import '../../../widgets/custom_notification.dart'; class VoiceRecordController { @@ -112,6 +113,7 @@ class VoiceRecordController { _locked.value = false; _lockDrag.value = 0; _isRecording.value = true; + unawaited(ScreenWake.instance.acquire(this)); FocusManager.instance.primaryFocus?.unfocus(); Haptics.send(); _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { @@ -131,6 +133,7 @@ class VoiceRecordController { } } catch (_) { _isRecording.value = false; + unawaited(ScreenWake.instance.release(this)); if (isMounted()) { showCustomNotification(contextOf(), 'Не удалось начать запись'); } @@ -172,6 +175,7 @@ class VoiceRecordController { final rec = _recorder; if (rec == null) { _isRecording.value = false; + unawaited(ScreenWake.instance.release(this)); return; } @@ -182,6 +186,7 @@ class VoiceRecordController { _stopwatch.stop(); final elapsed = _stopwatch.elapsedMilliseconds; _isRecording.value = false; + unawaited(ScreenWake.instance.release(this)); _cancelDrag.value = 0; _amplitude.value = 0; _locked.value = false; @@ -237,6 +242,7 @@ class VoiceRecordController { } void dispose() { + unawaited(ScreenWake.instance.release(this)); _timer?.cancel(); _ampSub?.cancel(); _recorder?.dispose(); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 0c4fee8..6fe53a6 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -2088,15 +2088,16 @@ class _ChatScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || - state == AppLifecycleState.inactive) { + state == AppLifecycleState.hidden || + state == AppLifecycleState.detached) { if (_voiceRec.isRecording.value) { unawaited(_voiceRec.stop(cancel: true)); } if (_note.isRecording.value) { unawaited(_note.stop(cancel: true)); } - _saveDraft(); } + if (state != AppLifecycleState.resumed) _saveDraft(); super.didChangeAppLifecycleState(state); } diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 251fe30..fac77a6 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -19,6 +19,9 @@ import 'password_entry_screen.dart'; import '../../../core/config/app_fonts.dart'; import '../../../core/config/app_shape.dart'; +const bool _showFamilyProtection = false; +const bool _showSafeMode = false; + class SecurityScreen extends StatefulWidget { const SecurityScreen({super.key}); @@ -165,9 +168,9 @@ class _SecurityScreenState extends State child: Column( children: [ _buildAppBar(context, cs), - _buildShimmerSection(cs, height: 104), + _buildShimmerSection(cs, height: _showFamilyProtection ? 104 : 56), const SizedBox(height: 12), - _buildShimmerSection(cs, height: 280), + _buildShimmerSection(cs, height: _showSafeMode ? 280 : 232), const SizedBox(height: 20), _buildShimmerSection(cs, height: 220), const SizedBox(height: 12), @@ -258,15 +261,16 @@ class _SecurityScreenState extends State child: Column( children: [ _buildPasswordRow(cs), - _settingsRow( - cs, - icon: Symbols.shield, - label: l10n.securityFamilyProtection, - subtitle: _privacyConfig?.familyProtection == 'ON' - ? l10n.securityEnabledFem - : l10n.securityDisabledFem, - isLast: true, - ), + if (_showFamilyProtection) + _settingsRow( + cs, + icon: Symbols.shield, + label: l10n.securityFamilyProtection, + subtitle: _privacyConfig?.familyProtection == 'ON' + ? l10n.securityEnabledFem + : l10n.securityDisabledFem, + isLast: true, + ), ], ), ); @@ -337,14 +341,15 @@ class _SecurityScreenState extends State ), ), ), - Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), + if (_showFamilyProtection) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), ), - ), ], ); } @@ -358,59 +363,61 @@ class _SecurityScreenState extends State depth: 6, child: Column( children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), - child: Row( - children: [ - Icon( - Symbols.lock, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.securityModeTitle, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - l10n.securityModeSubtitle, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: isSafeMode, - onChanged: (v) => showCustomNotification( - context, - l10n.securitySettingsUnavailable, - ), - ), - ], - ), - ), - if (isSafeMode) ...[ + if (_showSafeMode) Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.lock, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.securityModeTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + l10n.securityModeSubtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: isSafeMode, + onChanged: (v) => showCustomNotification( + context, + l10n.securitySettingsUnavailable, + ), + ), + ], ), ), + if (isSafeMode) ...[ + if (_showSafeMode) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), _settingsRow( cs, label: l10n.securityFindByPhone, @@ -465,14 +472,15 @@ class _SecurityScreenState extends State ), ], if (!isSafeMode) ...[ - Padding( - padding: const EdgeInsets.only(left: 20), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), + if (_showSafeMode) + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), ), - ), _settingsRow( cs, icon: Symbols.phone, diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 109fd7d..9445161 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -88,8 +88,11 @@ class AttachmentSheet extends StatefulWidget { } class _AttachmentSheetState extends State { + static const int _loadAhead = 24; + static List? _cachedItems; static GalleryPermission _cachedPermission = GalleryPermission.granted; + static bool _cachedHasMore = false; final GallerySource _source = GallerySource.create(); final ValueNotifier> _selected = ValueNotifier({}); @@ -108,6 +111,9 @@ class _AttachmentSheetState extends State { double _navDragAccumDx = 0; bool _loading = true; + bool _loadingMore = false; + bool _hasMore = false; + int _loadToken = 0; GalleryPermission _permission = GalleryPermission.granted; List _items = const []; @@ -118,6 +124,7 @@ class _AttachmentSheetState extends State { if (cached != null) { _items = cached; _permission = _cachedPermission; + _hasMore = _cachedHasMore; _loading = false; _loadGallery(silent: true); } else { @@ -141,26 +148,60 @@ class _AttachmentSheetState extends State { } Future _loadGallery({bool silent = false}) async { + final token = ++_loadToken; if (!silent) setState(() => _loading = true); final permission = await _source.ensurePermission(); - if (!mounted) return; + if (!mounted || token != _loadToken) return; if (permission == GalleryPermission.denied) { _cachedItems = null; + _cachedHasMore = false; setState(() { _permission = permission; _items = const []; + _hasMore = false; _loading = false; }); return; } - final items = await _source.load(limit: 120); - if (!mounted) return; - _cachedItems = items; - _cachedPermission = permission; + final loaded = _items.length; + final page = await _source.load( + offset: 0, + limit: loaded > GallerySource.pageSize ? loaded : GallerySource.pageSize, + ); + if (!mounted || token != _loadToken) return; + _permission = permission; + _loading = false; + _publishItems(page.items, page.hasMore); + } + + Future _loadMore() async { + if (_loading || _loadingMore || !_hasMore) return; + final token = _loadToken; + final offset = _items.length; + _loadingMore = true; + final page = await _source.load(offset: offset); + _loadingMore = false; + if (!mounted || token != _loadToken || offset != _items.length) return; + if (page.items.isEmpty) { + _cachedHasMore = false; + setState(() => _hasMore = false); + return; + } + _publishItems([..._items, ...page.items], page.hasMore); + } + + void _publishItems(List items, bool hasMore) { + final seen = {}; + final unique = [ + for (final item in items) + if (seen.add(item.id)) item, + ]; + _cachedItems = unique; + _cachedPermission = _permission; + _cachedHasMore = hasMore; setState(() { - _permission = permission; - _items = items; - _loading = false; + _items = unique; + _hasMore = hasMore; }); } @@ -619,12 +660,7 @@ class _AttachmentSheetState extends State { ), ), SliverPadding( - padding: EdgeInsets.fromLTRB( - hpad, - spacing, - hpad, - bottomReserve + 6, - ), + padding: const EdgeInsets.fromLTRB(hpad, spacing, hpad, 0), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, @@ -632,6 +668,9 @@ class _AttachmentSheetState extends State { crossAxisSpacing: spacing, ), delegate: SliverChildBuilderDelegate((context, index) { + if (index >= gridPhotos.length - _loadAhead) { + unawaited(_loadMore()); + } final item = gridPhotos[index]; return _GalleryTile( key: ValueKey(item.id), @@ -646,6 +685,18 @@ class _AttachmentSheetState extends State { }, childCount: gridPhotos.length), ), ), + SliverToBoxAdapter( + child: Column( + children: [ + if (_hasMore) + Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: SmallSpinner(size: 22, color: cs.primary), + ), + SizedBox(height: bottomReserve + 6), + ], + ), + ), ], ); }, diff --git a/lib/main.dart b/lib/main.dart index 559dd58..73dcaf8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -624,6 +624,9 @@ class KometAppState extends State if (state != AppLifecycleState.resumed) return; api.wakeUp(); SelfCheckService.instance.resume(); + if (!CallController.instance.isBusy) { + unawaited(CallBridge.instance.dropOngoing()); + } CallBridge.instance.checkInitialCall(); unawaited(NotificationBridge.instance.checkInitialChat()); unawaited(ShareIntentBridge.instance.checkInitialShare());