// SPDX-FileCopyrightText: 2019-Present Christian Kußowski // SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'dart:convert'; import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart'; import 'package:tatchat/config/miniapp_config.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/apps/apps.dart'; import 'package:tatchat/utils/miniapp_store.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; import 'package:tatchat/widgets/layouts/max_width_body.dart'; class AppsView extends StatelessWidget { final AppsController controller; const AppsView(this.controller, {super.key}); @override Widget build(BuildContext context) { final installed = controller.client.installedMiniApps; final installedApps = installedMiniAppConfigs(controller.client); final availableApps = availableMiniApps .where((app) => !installed.contains(app.id)) .toList(); return Scaffold( appBar: AppBar( leading: const Center(child: BackButton()), title: Text(L10n.of(context).apps), backgroundColor: Theme.of(context).colorScheme.surface, surfaceTintColor: Colors.transparent, actions: [ PopupMenuButton<_ImportAction>( icon: const Icon(Icons.add_box_outlined), tooltip: L10n.of(context).importApp, onSelected: (action) async { switch (action) { case _ImportAction.file: try { await controller.importMiniAppFromFile(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(L10n.of(context).appImported)), ); } catch (e, s) { Logs().e('Failed to import mini app', e, s); if (!context.mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(e.toString()))); } case _ImportAction.url: final url = await showTextInputDialog( context: context, title: L10n.of(context).importFromUrl, okLabel: L10n.of(context).importApp, ); if (url == null || url.trim().isEmpty) break; try { await controller.importMiniAppFromUrl(url); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(L10n.of(context).appImported)), ); } catch (e, s) { Logs().e('Failed to import mini app', e, s); if (!context.mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(e.toString()))); } } }, itemBuilder: (context) => [ PopupMenuItem( value: _ImportAction.file, child: Row( children: [ const Icon(Icons.upload_file_outlined), const SizedBox(width: 12), Text(L10n.of(context).importFromFile), ], ), ), PopupMenuItem( value: _ImportAction.url, child: Row( children: [ const Icon(Icons.link), const SizedBox(width: 12), Text(L10n.of(context).importFromUrl), ], ), ), ], ), ], ), body: MaxWidthBody( withScrolling: false, child: ListView( padding: const EdgeInsets.all(16), children: [ if (installedApps.isNotEmpty) ...[ _SectionHeader(L10n.of(context).installedApps), for (final app in installedApps) _AppCard( app: app, onTap: () => context.go('/rooms/apps/${app.id}'), trailing: [ _AppCardMenu( onUninstall: () => controller.uninstallMiniApp(app.id), ), ], ), ], if (availableApps.isNotEmpty) ...[ _SectionHeader(L10n.of(context).availableApps), for (final app in availableApps) _AppCard( app: app, onTap: null, trailing: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: TextButton( onPressed: () => controller.installMiniApp(app.id), child: Text(L10n.of(context).install), ), ), ], ), ], ], ), ), ); } } enum _ImportAction { file, url } class _SectionHeader extends StatelessWidget { final String text; const _SectionHeader(this.text); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: Text(text, style: Theme.of(context).textTheme.titleSmall), ); } } class _AppCard extends StatelessWidget { final MiniAppConfig app; final VoidCallback? onTap; final List trailing; const _AppCard({required this.app, required this.trailing, this.onTap}); @override Widget build(BuildContext context) { final category = app.category?.trim() ?? ''; final description = app.description?.trim() ?? ''; final subtitle = [ if (category.isNotEmpty) category, if (description.isNotEmpty) description, ].join(' · '); final name = miniAppName(context, app); return Card( margin: const EdgeInsets.symmetric(vertical: 4), child: ListTile( onTap: onTap, leading: _Logo(logo: app.logo, color: app.color, name: name), title: Text(name), subtitle: subtitle.isEmpty ? null : Text( subtitle, maxLines: 3, overflow: TextOverflow.ellipsis, ), trailing: Row(mainAxisSize: MainAxisSize.min, children: trailing), ), ); } } enum _AppMenuAction { uninstall } /// Trailing three-dot menu of an installed app. Uninstalling asks for /// confirmation before it is executed. class _AppCardMenu extends StatelessWidget { final VoidCallback onUninstall; const _AppCardMenu({required this.onUninstall}); @override Widget build(BuildContext context) { return PopupMenuButton<_AppMenuAction>( icon: const Icon(Icons.more_vert), tooltip: L10n.of(context).moreOptions, onSelected: (action) async { switch (action) { case _AppMenuAction.uninstall: final result = await showOkCancelAlertDialog( context: context, title: L10n.of(context).uninstallApp, message: L10n.of(context).uninstallAppConfirm, okLabel: L10n.of(context).uninstall, isDestructive: true, ); if (result == OkCancelResult.ok) onUninstall(); } }, itemBuilder: (context) => [ PopupMenuItem<_AppMenuAction>( value: _AppMenuAction.uninstall, child: Row( children: [ Icon( Icons.delete_outline, color: Theme.of(context).colorScheme.error, ), const SizedBox(width: 12), Text(L10n.of(context).uninstall), ], ), ), ], ); } } /// Shows the logo declared by the mini app (data URI or http(s) URL), falling /// back to the initial letter of the app name when no usable logo is /// available. class _Logo extends StatelessWidget { final String? logo; final Color color; final String name; const _Logo({required this.logo, required this.color, required this.name}); @override Widget build(BuildContext context) { final value = logo?.trim() ?? ''; Widget? image; if (value.startsWith('data:image/')) { final comma = value.indexOf(','); if (comma != -1 && value.substring(0, comma).contains('base64')) { try { final bytes = base64Decode(value.substring(comma + 1)); image = ClipOval( child: Image.memory( bytes, width: 48, height: 48, fit: BoxFit.cover, ), ); } catch (_) {} } } else if (value.startsWith('http://') || value.startsWith('https://')) { image = ClipOval( child: Image.network(value, width: 48, height: 48, fit: BoxFit.cover), ); } if (image != null) return CircleAvatar(radius: 24, child: image); final initial = name.trim().isEmpty ? '?' : name.trim()[0].toUpperCase(); return CircleAvatar( radius: 24, backgroundColor: color.withValues(alpha: .15), foregroundColor: color, child: Text( initial, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), ), ); } }