feat: tabbed settings dialog (pill tabs, full-bleed panel), hover color setting, title bar grab cursor (v1.9.2)
This commit is contained in:
1 parent
1a6de71ff6
commit
701eafa744
12 files changed
+1064
-221
No files matched your search
+325
-106
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
@@ -11,6 +12,7 @@ import '../services/image_header.dart';
|
||||
import '../services/image_manager.dart';
|
||||
import '../services/file_handler.dart';
|
||||
import '../services/crop_save_service.dart';
|
||||
import '../services/hover_color.dart';
|
||||
import '../widgets/custom_title_bar.dart';
|
||||
import '../widgets/image_canvas.dart';
|
||||
import '../widgets/window_resize_zones.dart';
|
||||
@@ -19,7 +21,7 @@ import '../shortcuts/app_shortcuts.dart';
|
||||
import '../services/window_persistence.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
const appVersion = '1.9.0';
|
||||
const appVersion = '1.9.2';
|
||||
|
||||
class ViewerScreen extends StatefulWidget {
|
||||
final List<String> initialFiles;
|
||||
@@ -114,113 +116,327 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
||||
}
|
||||
}
|
||||
|
||||
static const double _shortcutKeyColumnWidth = 126;
|
||||
|
||||
int _settingsTab = 0;
|
||||
|
||||
void _showSettings() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF2a2a2a),
|
||||
title: Text(AppLocalizations.of(context)!.settingsTitle,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18)),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_hasImages) ...[
|
||||
Text(AppLocalizations.of(context)!.fileProperties,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<List<(String, String)>>(
|
||||
future: _fileProperties(ImageManager.instance.currentImagePath),
|
||||
builder: (context, snap) {
|
||||
if (!snap.hasData) return const SizedBox(height: 20);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: snap.data!.map((r) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(text: '${r.$1}: ', style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
TextSpan(text: r.$2, style: const TextStyle(color: Colors.white, fontSize: 13)),
|
||||
]),
|
||||
builder: (context, setDialogState) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Dialog(
|
||||
insetPadding: EdgeInsets.zero,
|
||||
backgroundColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) => Container(
|
||||
width: math.min(c.maxWidth, 520),
|
||||
height: math.min(c.maxHeight, 560),
|
||||
padding: const EdgeInsets.fromLTRB(14, 4, 14, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2a2a2a),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(l10n.settingsTitle,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.print,
|
||||
icon: const Icon(Icons.print, color: Colors.white70, size: 18),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_printImage();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.close,
|
||||
icon: const Icon(Icons.close, color: Colors.white70, size: 18),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: SegmentedButton<int>(
|
||||
expandedInsets: EdgeInsets.zero,
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
label: Text(l10n.tabGeneral,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
)).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(color: Colors.white24, height: 24),
|
||||
],
|
||||
WindowSizeSelector(
|
||||
workArea: _workArea,
|
||||
onPick: _applyWindowSize,
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
label: Text(l10n.sizeSection,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 2,
|
||||
label: Text(l10n.shortcuts,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
selected: {_settingsTab},
|
||||
showSelectedIcon: false,
|
||||
style: _segmentedStyle,
|
||||
onSelectionChanged: (selection) =>
|
||||
setDialogState(() => _settingsTab = selection.first),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: _settingsTabBody(l10n, setDialogState),
|
||||
),
|
||||
const Divider(color: Colors.white10, height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text('imajViewer $appVersion',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(l10n.appDescription,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 24),
|
||||
Text(AppLocalizations.of(context)!.sortBy,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
RadioListTile<bool>(
|
||||
value: false,
|
||||
groupValue: ImageManager.instance.sortByName,
|
||||
dense: true,
|
||||
activeColor: Colors.blueAccent,
|
||||
title: Text(AppLocalizations.of(context)!.sortByTime,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
onChanged: (v) {
|
||||
ImageManager.instance.setSortByName(v!);
|
||||
setDialogState(() {});
|
||||
},
|
||||
),
|
||||
RadioListTile<bool>(
|
||||
value: true,
|
||||
groupValue: ImageManager.instance.sortByName,
|
||||
dense: true,
|
||||
activeColor: Colors.blueAccent,
|
||||
title: Text(AppLocalizations.of(context)!.sortByName,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
onChanged: (v) {
|
||||
ImageManager.instance.setSortByName(v!);
|
||||
setDialogState(() {});
|
||||
},
|
||||
),
|
||||
Text('imajViewer $appVersion',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.appDescription,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(AppLocalizations.of(context)!.shortcuts,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.shortcutsList,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_printImage();
|
||||
},
|
||||
icon: const Icon(Icons.print, color: Colors.white70, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.print,
|
||||
style: const TextStyle(color: Colors.white70)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context)!.close, style: const TextStyle(color: Colors.white70)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _settingsTabBody(AppLocalizations l10n, StateSetter setDialogState) {
|
||||
switch (_settingsTab) {
|
||||
case 1:
|
||||
return WindowSizeSelector(workArea: _workArea, onPick: _applyWindowSize);
|
||||
case 2:
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (key, action) in _shortcutRows(l10n))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _shortcutKeyColumnWidth,
|
||||
child: Text(key,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(action,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
default:
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_hasImages) ...[
|
||||
_settingsCard(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle(l10n.fileProperties),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<List<(String, String)>>(
|
||||
future: _fileProperties(ImageManager.instance.currentImagePath),
|
||||
builder: (context, snap) {
|
||||
if (!snap.hasData) return const SizedBox(height: 20);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: snap.data!.map((r) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(text: '${r.$1}: ',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
TextSpan(text: r.$2,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13)),
|
||||
]),
|
||||
),
|
||||
)).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_settingsCard(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle(l10n.sortBy),
|
||||
const SizedBox(height: 4),
|
||||
SegmentedButton<bool>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
label: Text(l10n.sortByTime, style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
label: Text(l10n.sortByName, style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
selected: {ImageManager.instance.sortByName},
|
||||
showSelectedIcon: false,
|
||||
style: _segmentedStyle,
|
||||
onSelectionChanged: (selection) {
|
||||
ImageManager.instance.setSortByName(selection.first);
|
||||
setDialogState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_settingsCard(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle(l10n.hoverColor),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
for (final color in HoverColor.palette)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
HoverColor.set(color);
|
||||
setDialogState(() {});
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: HoverColor.notifier.value.toARGB32() ==
|
||||
color.toARGB32()
|
||||
? Colors.white
|
||||
: Colors.white24,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_settingsCard(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle(l10n.language),
|
||||
const SizedBox(height: 6),
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: 'en',
|
||||
label: Text('English', style: TextStyle(fontSize: 13))),
|
||||
ButtonSegment(
|
||||
value: 'tr',
|
||||
label: Text('Türkçe', style: TextStyle(fontSize: 13))),
|
||||
ButtonSegment(
|
||||
value: 'fr',
|
||||
label: Text('Français', style: TextStyle(fontSize: 13))),
|
||||
],
|
||||
selected: const {'tr'},
|
||||
showSelectedIcon: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String text) {
|
||||
return Text(text,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600));
|
||||
}
|
||||
|
||||
ButtonStyle get _segmentedStyle => ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected)
|
||||
? Colors.blueAccent.withValues(alpha: 0.25)
|
||||
: Colors.white10,
|
||||
),
|
||||
foregroundColor: const WidgetStatePropertyAll(Colors.white),
|
||||
side: const WidgetStatePropertyAll(BorderSide(color: Colors.white24)),
|
||||
);
|
||||
|
||||
Widget _settingsCard(Widget child) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
List<(String, String)> _shortcutRows(AppLocalizations l10n) {
|
||||
return [
|
||||
(l10n.scNavKey, l10n.scNavAction),
|
||||
(l10n.scOpenKey, l10n.scOpenAction),
|
||||
(l10n.scSaveKey, l10n.scSaveAction),
|
||||
(l10n.scFlipKey, l10n.scFlipAction),
|
||||
(l10n.scCloseKey, l10n.scCloseAction),
|
||||
(l10n.scCloseAllKey, l10n.scCloseAllAction),
|
||||
(l10n.scFullscreenKey, l10n.scFullscreenAction),
|
||||
(l10n.scExitFullscreenKey, l10n.scExitFullscreenAction),
|
||||
(l10n.scZoomKey, l10n.scZoomAction),
|
||||
(l10n.scFineZoomKey, l10n.scFineZoomAction),
|
||||
(l10n.scPanKey, l10n.scPanAction),
|
||||
(l10n.scFillKey, l10n.scFillAction),
|
||||
(l10n.scResetKey, l10n.scResetAction),
|
||||
(l10n.scContrastKey, l10n.scContrastAction),
|
||||
(l10n.scRotateKey, l10n.scRotateAction),
|
||||
(l10n.scSaturationKey, l10n.scSaturationAction),
|
||||
(l10n.scBrightnessKey, l10n.scBrightnessAction),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _printImage() async {
|
||||
final path = ImageManager.instance.currentImagePath;
|
||||
if (path.isEmpty) return;
|
||||
@@ -496,16 +712,19 @@ class _NavButtonState extends State<_NavButton> {
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: Colors.black.withValues(alpha: 0.35),
|
||||
shape: BoxShape.circle,
|
||||
child: ValueListenableBuilder<Color>(
|
||||
valueListenable: HoverColor.notifier,
|
||||
builder: (context, hover, _) => Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? hover.withValues(alpha: 0.8)
|
||||
: Colors.black.withValues(alpha: 0.35),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(widget.icon, color: Colors.white, size: 22),
|
||||
),
|
||||
child: Icon(widget.icon, color: Colors.white, size: 22),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user