diff --git a/lib/view/pages/files/search/files_search_controller.dart b/lib/view/pages/files/search/files_search_controller.dart index 5a75249..21e0399 100644 --- a/lib/view/pages/files/search/files_search_controller.dart +++ b/lib/view/pages/files/search/files_search_controller.dart @@ -33,7 +33,7 @@ class FilesSearchController extends ChangeNotifier { /// on a disposed `ChangeNotifier`. void _safeNotify() { if (_disposed) return; - _safeNotify(); + notifyListeners(); } String get query => _query; diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 9d3ab72..55b09ef 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -1,20 +1,25 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'package:chewie/chewie.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:open_filex/open_filex.dart'; import 'package:photo_view/photo_view.dart'; import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import 'package:video_player/video_player.dart'; import '../routing/app_routes.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; +import 'app_progress_indicator.dart'; +import 'centered_leading.dart'; import 'info_dialog.dart'; -import 'placeholder_view.dart'; import 'share_position_origin.dart'; class FileViewer extends StatefulWidget { @@ -39,6 +44,88 @@ class FileViewer extends StatefulWidget { enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } +enum _FileKind { image, svg, pdf, text, video, audio, unknown } + +const Set _imageExtensions = { + 'png', + 'jpg', + 'jpeg', + 'webp', + 'gif', + 'bmp', + 'wbmp', +}; + +/// Video container formats whose playback the platform decoders (ExoPlayer +/// on Android, AVPlayer on iOS) handle out of the box. +const Set _videoExtensions = { + 'mp4', + 'm4v', + 'mov', + 'webm', + 'mkv', + '3gp', +}; + +/// Audio formats playable through the same `video_player` pipeline. Some +/// (ogg/opus/flac) work on Android only — iOS will surface an init error +/// which we catch and surface as a friendly fallback. +const Set _audioExtensions = { + 'mp3', + 'm4a', + 'aac', + 'wav', + 'flac', + 'ogg', + 'oga', + 'opus', +}; + +/// Extensions whose contents we render directly as plain text. Anything +/// outside this list still gets a content-based fallback check (see +/// [_looksLikeText]) so generic "what is this file" cases work too. +const Set _textExtensions = { + 'txt', 'md', 'markdown', 'rst', 'log', + 'json', 'json5', 'xml', 'yaml', 'yml', 'toml', + 'csv', 'tsv', 'tab', + 'ini', 'conf', 'cfg', 'env', 'properties', + 'html', 'htm', 'xhtml', + 'css', 'scss', 'sass', 'less', + 'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx', + 'dart', 'java', 'kt', 'kts', 'groovy', 'scala', 'swift', + 'py', 'rb', 'pl', 'lua', 'r', + 'go', 'rs', 'zig', + 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'm', 'mm', + 'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', + 'sql', 'graphql', 'gql', + 'gitignore', 'gitattributes', 'editorconfig', 'dockerignore', + 'dockerfile', 'makefile', 'cmake', + 'tex', 'bib', + 'srt', 'vtt', +}; + +/// Reads up to 8 KB and decides whether the bytes look like UTF-8 text. +/// NUL bytes and non-decodable sequences disqualify the file. Used as a +/// fallback for unknown extensions so plain text files without a familiar +/// suffix still open in the in-app viewer. +Future _looksLikeText(String path) async { + final file = File(path); + RandomAccessFile? raf; + try { + final length = await file.length(); + if (length == 0) return true; + raf = await file.open(); + final sample = await raf.read(min(length, 8192)); + if (sample.contains(0)) return false; + utf8.decode(sample); + return true; + } on Object { + return false; + } finally { + await raf?.close(); + } +} + /// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal /// LayoutBuilder calls `localToGlobal` during build, which asserts when an /// ancestor RenderTransform (from the page-push animation) is still mid-layout. @@ -82,7 +169,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> { @override Widget build(BuildContext context) { if (!_ready) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: AppProgressIndicator.large()); } return SfPdfViewer.file(File(widget.path)); } @@ -93,13 +180,32 @@ class _FileViewerState extends State { late SettingsCubit settings = context.read(); late bool openExternal; + Future<_FileKind>? _fileKind; @override void initState() { + super.initState(); openExternal = settings.val().fileViewSettings.alwaysOpenExternally || widget.openExternal; - super.initState(); + if (openExternal) { + // Settings or popup explicitly chose "open externally" — fire and + // forget, then pop back. Same one-shot behaviour as the old viewer. + WidgetsBinding.instance.addPostFrameCallback( + (_) => _openExternallyAndPop(), + ); + } else { + _fileKind = _detectKind(); + } + } + + Future _openExternallyAndPop() async { + final result = await OpenFilex.open(widget.path); + if (!mounted) return; + Navigator.of(context).pop(); + if (result.type != ResultType.done) { + InfoDialog.show(context, result.message); + } } @override @@ -108,167 +214,616 @@ class _FileViewerState extends State { super.dispose(); } + Future<_FileKind> _detectKind() async { + final ext = widget.path.split('.').last.toLowerCase(); + if (_imageExtensions.contains(ext)) return _FileKind.image; + if (ext == 'svg') return _FileKind.svg; + if (ext == 'pdf') return _FileKind.pdf; + if (_videoExtensions.contains(ext)) return _FileKind.video; + if (_audioExtensions.contains(ext)) return _FileKind.audio; + if (_textExtensions.contains(ext)) return _FileKind.text; + if (await _looksLikeText(widget.path)) return _FileKind.text; + return _FileKind.unknown; + } + + Future _handleAction(FileViewingActions value) async { + switch (value) { + case FileViewingActions.openExternal: + AppRoutes.openFileViewer( + context, + widget.path, + openExternal: true, + remoteFile: widget.remoteFile, + ); + break; + case FileViewingActions.sendToChat: + AppRoutes.openInternalShareToChat(context, widget.remoteFile!); + break; + case FileViewingActions.saveToCloud: + AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!); + break; + case FileViewingActions.share: + unawaited( + SharePlus.instance.share( + ShareParams( + files: [XFile(widget.path)], + sharePositionOrigin: SharePositionOrigin.get(context), + ), + ), + ); + break; + case FileViewingActions.save: + try { + final bytes = await File(widget.path).readAsBytes(); + final saved = await FilePicker.saveFile( + fileName: widget.path.split('/').last, + bytes: bytes, + ); + if (!mounted) return; + if (saved != null) { + InfoDialog.show(context, 'Datei gespeichert.'); + } + } on Object catch (e) { + if (!mounted) return; + InfoDialog.show( + context, + 'Speichern fehlgeschlagen: $e', + copyable: true, + title: 'Fehler', + ); + } + break; + } + } + + List<_ActionDescriptor> _availableActions() => [ + _ActionDescriptor( + action: FileViewingActions.openExternal, + // iOS opens the system share sheet (square-with-arrow icon), Android + // the standard app picker; mirror that visually and verbally. + icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new, + label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit', + ), + if (widget.remoteFile != null) ...[ + const _ActionDescriptor( + action: FileViewingActions.sendToChat, + icon: Icons.chat_bubble_outline, + label: 'An Talk-Chat senden', + ), + const _ActionDescriptor( + action: FileViewingActions.saveToCloud, + icon: Icons.cloud_outlined, + label: 'In Cloud speichern', + ), + ], + const _ActionDescriptor( + action: FileViewingActions.share, + icon: Icons.share_outlined, + label: 'Teilen', + ), + const _ActionDescriptor( + action: FileViewingActions.save, + icon: Icons.save_alt_outlined, + label: 'Speichern', + ), + ]; + + AppBar _appbar({ + List actions = const [], + bool showActionsMenu = true, + }) => AppBar( + title: Text(widget.path.split('/').last), + actions: [ + ...actions, + if (showActionsMenu) + PopupMenuButton( + onSelected: _handleAction, + itemBuilder: (context) => _availableActions() + .map( + (a) => PopupMenuItem( + value: a.action, + child: ListTile( + leading: Icon(a.icon), + title: Text(a.label), + dense: true, + ), + ), + ) + .toList(), + ), + ], + ); + @override Widget build(BuildContext context) { - AppBar appbar({List actions = const []}) => AppBar( - title: Text(widget.path.split('/').last), + if (openExternal) { + return Scaffold( + appBar: AppBar(title: Text(widget.path.split('/').last)), + body: const Center(child: AppProgressIndicator.large()), + ); + } + return FutureBuilder<_FileKind>( + future: _fileKind, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: _appbar(), + body: const Center(child: AppProgressIndicator.large()), + ); + } + switch (snapshot.data!) { + case _FileKind.image: + return _buildImageView(); + case _FileKind.svg: + return _buildSvgView(); + case _FileKind.pdf: + return _buildPdfView(); + case _FileKind.video: + return _buildVideoView(); + case _FileKind.audio: + return _buildAudioView(); + case _FileKind.text: + return _buildTextView(); + case _FileKind.unknown: + return _buildUnknownView(); + } + }, + ); + } + + Widget _buildImageView() => Scaffold( + appBar: _appbar( actions: [ - ...actions, - PopupMenuButton( - onSelected: (value) async { - switch (value) { - case FileViewingActions.openExternal: - AppRoutes.openFileViewer( - context, - widget.path, - openExternal: true, - remoteFile: widget.remoteFile, - ); - break; - case FileViewingActions.sendToChat: - AppRoutes.openInternalShareToChat(context, widget.remoteFile!); - break; - case FileViewingActions.saveToCloud: - AppRoutes.openInternalSaveToFolder( - context, - widget.remoteFile!, - ); - break; - case FileViewingActions.share: - unawaited( - SharePlus.instance.share( - ShareParams( - files: [XFile(widget.path)], - sharePositionOrigin: SharePositionOrigin.get(context), - ), - ), - ); - break; - case FileViewingActions.save: - try { - final bytes = await File(widget.path).readAsBytes(); - final saved = await FilePicker.saveFile( - fileName: widget.path.split('/').last, - bytes: bytes, - ); - if (!context.mounted) return; - if (saved != null) { - InfoDialog.show(context, 'Datei gespeichert.'); - } - } on Object catch (e) { - if (!context.mounted) return; - InfoDialog.show( - context, - 'Speichern fehlgeschlagen: $e', - copyable: true, - title: 'Fehler', - ); - } - break; - } + IconButton( + onPressed: () { + setState(() { + photoViewController.rotation += pi / 2; + }); }, - itemBuilder: (context) => >[ - const PopupMenuItem( - value: FileViewingActions.openExternal, - child: ListTile( - leading: Icon(Icons.open_in_new), - title: Text('Extern öffnen'), - dense: true, - ), - ), - if (widget.remoteFile != null) ...[ - const PopupMenuItem( - value: FileViewingActions.sendToChat, - child: ListTile( - leading: Icon(Icons.chat_bubble_outline), - title: Text('An Talk-Chat senden'), - dense: true, - ), - ), - const PopupMenuItem( - value: FileViewingActions.saveToCloud, - child: ListTile( - leading: Icon(Icons.cloud_outlined), - title: Text('In Cloud speichern'), - dense: true, - ), - ), - ], - const PopupMenuItem( - value: FileViewingActions.share, - child: ListTile( - leading: Icon(Icons.share_outlined), - title: Text('Teilen'), - dense: true, - ), - ), - const PopupMenuItem( - value: FileViewingActions.save, - child: ListTile( - leading: Icon(Icons.save_alt_outlined), - title: Text('Speichern'), - dense: true, - ), - ), - ], + icon: const Icon(Icons.rotate_right), ), ], - ); + ), + backgroundColor: Colors.white, + body: PhotoView( + controller: photoViewController, + maxScale: 3.0, + minScale: 0.1, + imageProvider: Image.file(File(widget.path)).image, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), + ), + ); - switch (openExternal ? '' : widget.path.split('.').last.toLowerCase()) { - case 'png': - case 'jpg': - case 'jpeg': - case 'webp': - case 'gif': - return Scaffold( - appBar: appbar( - actions: [ - IconButton( - onPressed: () { - setState(() { - photoViewController.rotation += pi / 2; - }); - }, - icon: const Icon(Icons.rotate_right), - ), - ], - ), - backgroundColor: Colors.white, - body: PhotoView( - controller: photoViewController, - maxScale: 3.0, - minScale: 0.1, - imageProvider: Image.file(File(widget.path)).image, - backgroundDecoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, + Widget _buildSvgView() => Scaffold( + appBar: _appbar(), + backgroundColor: Colors.white, + body: InteractiveViewer( + minScale: 0.5, + maxScale: 8, + child: Center( + child: SvgPicture.file( + File(widget.path), + placeholderBuilder: (_) => + const Center(child: AppProgressIndicator.large()), + ), + ), + ), + ); + + Widget _buildPdfView() => + Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path)); + + Widget _buildVideoView() => Scaffold( + appBar: _appbar(), + backgroundColor: Colors.black, + body: _MediaPlayer(path: widget.path, isAudio: false), + ); + + Widget _buildAudioView() => Scaffold( + appBar: _appbar(), + body: _MediaPlayer( + path: widget.path, + isAudio: true, + filename: widget.path.split('/').last, + ), + ); + + Widget _buildTextView() => Scaffold( + appBar: _appbar(), + body: FutureBuilder<_TextPayload>( + future: _readTextPayload(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: AppProgressIndicator.large()); + } + final payload = snapshot.data!; + final lines = const LineSplitter().convert(payload.content); + // Reserve gutter width by the digit count of the highest line number, + // so the gutter stays stable as the user scrolls down. + final gutterWidth = (lines.length.toString().length * 9.0) + 16; + return SelectionArea( + child: Scrollbar( + child: CustomScrollView( + slivers: [ + if (payload.truncated) + SliverToBoxAdapter( + child: SelectionContainer.disabled( + child: Container( + width: double.infinity, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + 'Datei ist groß — Anzeige auf die ersten ${(_textViewMaxBytes / 1024).round()} KB begrenzt.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ), + SliverList.builder( + itemCount: lines.length, + itemBuilder: (context, i) => _CodeLine( + number: i + 1, + text: lines[i], + gutterWidth: gutterWidth, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], ), ), ); + }, + ), + ); - case 'pdf': - return Scaffold( - appBar: appbar(), - body: _DeferredPdfViewer(path: widget.path), - ); - - default: - OpenFilex.open(widget.path).then((result) { - if (!context.mounted) return; - Navigator.of(context).pop(); - if (result.type != ResultType.done) { - InfoDialog.show(context, result.message); - } - }); - - return PlaceholderView( - text: 'Datei extern geöffnet', - icon: Icons.open_in_new, - button: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Zurück'), + Widget _buildUnknownView() { + final theme = Theme.of(context); + final descriptors = _availableActions(); + return Scaffold( + appBar: _appbar(showActionsMenu: false), + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 24), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const Icon(Icons.insert_drive_file_outlined, size: 60), + const SizedBox(height: 16), + Text( + 'Vorschau nicht verfügbar', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + widget.path.split('/').last, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Wähle eine Aktion, um mit der Datei weiterzuarbeiten.', + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), ), - ); + const SizedBox(height: 24), + ...descriptors.map( + (d) => ListTile( + leading: CenteredLeading(Icon(d.icon)), + title: Text(d.label), + onTap: () => _handleAction(d.action), + ), + ), + ], + ), + ); + } + + static const int _textViewMaxBytes = 5 * 1024 * 1024; + + Future<_TextPayload> _readTextPayload() async { + final file = File(widget.path); + final size = await file.length(); + final ext = widget.path.split('.').last.toLowerCase(); + if (size <= _textViewMaxBytes) { + final raw = await file.readAsString(); + return _TextPayload(content: _maybePrettify(raw, ext), truncated: false); + } + final raf = await file.open(); + try { + final bytes = await raf.read(_textViewMaxBytes); + // Truncated payloads cannot be reliably re-formatted (parser will + // choke on the dangling tail), so they stay raw. + return _TextPayload( + content: utf8.decode(bytes, allowMalformed: true), + truncated: true, + ); + } finally { + await raf.close(); + } + } + + /// Re-indents JSON so dumped/minified payloads from the server are easier + /// to read. Falls through to the original text on parse errors so we + /// never destroy the user's content. + String _maybePrettify(String content, String ext) { + if (ext != 'json') return content; + try { + final parsed = jsonDecode(content); + return const JsonEncoder.withIndent(' ').convert(parsed); + } on Object { + return content; } } } + +class _ActionDescriptor { + final FileViewingActions action; + final IconData icon; + final String label; + const _ActionDescriptor({ + required this.action, + required this.icon, + required this.label, + }); +} + +class _TextPayload { + final String content; + final bool truncated; + const _TextPayload({required this.content, required this.truncated}); +} + +/// Plays back a local file via `video_player`. Renders the standard Chewie +/// controls for video files; audio files get a centered icon plus a custom +/// transport row (slider, time, play/pause), since Chewie's chrome is +/// designed around a video frame. +class _MediaPlayer extends StatefulWidget { + final String path; + final bool isAudio; + final String? filename; + const _MediaPlayer({ + required this.path, + required this.isAudio, + this.filename, + }); + + @override + State<_MediaPlayer> createState() => _MediaPlayerState(); +} + +class _MediaPlayerState extends State<_MediaPlayer> { + VideoPlayerController? _video; + ChewieController? _chewie; + Object? _initError; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + final controller = VideoPlayerController.file(File(widget.path)); + try { + await controller.initialize(); + } on Object catch (e) { + await controller.dispose(); + if (!mounted) return; + setState(() => _initError = e); + return; + } + if (!mounted) { + await controller.dispose(); + return; + } + if (widget.isAudio) { + controller.addListener(_onAudioTick); + setState(() => _video = controller); + } else { + setState(() { + _video = controller; + _chewie = ChewieController( + videoPlayerController: controller, + autoPlay: false, + looping: false, + allowFullScreen: true, + allowPlaybackSpeedChanging: true, + ); + }); + } + } + + void _onAudioTick() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _video?.removeListener(_onAudioTick); + _chewie?.dispose(); + _video?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_initError != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48), + const SizedBox(height: 12), + Text( + widget.isAudio + ? 'Audio kann nicht abgespielt werden' + : 'Video kann nicht abgespielt werden', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Format wird auf diesem Gerät nicht unterstützt. Über das Menü kannst du die Datei in einer anderen App öffnen.', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + if (_video == null) { + return const Center(child: AppProgressIndicator.large()); + } + if (widget.isAudio) { + return _AudioControls( + controller: _video!, + filename: widget.filename ?? '', + ); + } + return Chewie(controller: _chewie!); + } +} + +class _AudioControls extends StatelessWidget { + final VideoPlayerController controller; + final String filename; + const _AudioControls({required this.controller, required this.filename}); + + String _format(Duration d) { + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + if (d.inHours > 0) return '${d.inHours}:$m:$s'; + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final value = controller.value; + final duration = value.duration; + final position = value.position; + final maxMs = duration.inMilliseconds == 0 ? 1 : duration.inMilliseconds; + final posMs = position.inMilliseconds.clamp(0, maxMs).toDouble(); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.audiotrack, + size: 96, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + filename, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Slider( + min: 0, + max: maxMs.toDouble(), + value: posMs, + onChanged: (v) => + controller.seekTo(Duration(milliseconds: v.toInt())), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _format(position), + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + _format(duration), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(height: 24), + FloatingActionButton( + heroTag: 'audioPlayPause', + onPressed: () { + if (value.isPlaying) { + controller.pause(); + } else { + controller.play(); + } + }, + child: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow), + ), + ], + ), + ), + ); + } +} + +/// One row in the text viewer: line number on the left (not selectable so +/// it never ends up in copied selections), monospace content on the right. +/// Odd-numbered lines get a slightly tinted background so long files are +/// easier to scan. +class _CodeLine extends StatelessWidget { + final int number; + final String text; + final double gutterWidth; + const _CodeLine({ + required this.number, + required this.text, + required this.gutterWidth, + }); + + static const TextStyle _codeStyle = TextStyle( + fontFamily: 'monospace', + fontSize: 13, + height: 1.4, + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isEven = number.isEven; + return Container( + color: isEven ? theme.colorScheme.surfaceContainerLow : null, + padding: const EdgeInsets.only(left: 4, right: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectionContainer.disabled( + child: SizedBox( + width: gutterWidth, + child: Text( + '$number', + textAlign: TextAlign.right, + style: _codeStyle.copyWith(color: theme.hintColor), + ), + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(text.isEmpty ? ' ' : text, style: _codeStyle)), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 34428e3..660c69b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,6 +74,8 @@ dependencies: url_launcher: ^6.3.1 enough_icalendar: ^0.17.0 receive_sharing_intent: ^1.8.1 + video_player: ^2.9.0 + chewie: ^1.8.5 dev_dependencies: flutter_test: