Add cxyz Android project structure and initial configuration
- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration. - Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files. - Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown. - Configured analysis options and dependencies in pubspec.yaml to support Flutter development. - Established a pnpm workspace and lockfile for dependency management. - Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/providers.dart';
|
||||
import '../services/ntfy_settings.dart';
|
||||
import '../services/reader_direction.dart';
|
||||
import '../services/webdav_settings.dart';
|
||||
|
||||
class SettingsPage extends ConsumerStatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
final _urlCtrl = TextEditingController();
|
||||
final _userCtrl = TextEditingController();
|
||||
final _passCtrl = TextEditingController();
|
||||
final _pathCtrl = TextEditingController();
|
||||
|
||||
final _ntfyCompletedCtrl = TextEditingController();
|
||||
final _ntfyFailedCtrl = TextEditingController();
|
||||
final _ntfyDrainedCtrl = TextEditingController();
|
||||
final _ntfyTokenCtrl = TextEditingController();
|
||||
|
||||
bool _enabled = false;
|
||||
bool _ntfyEnabled = false;
|
||||
NtfyAttachmentMode _ntfyAttachment = NtfyAttachmentMode.none;
|
||||
bool _loading = true;
|
||||
bool _busy = false;
|
||||
String? _status;
|
||||
String? _error;
|
||||
DateTime? _lastSync;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlCtrl.dispose();
|
||||
_userCtrl.dispose();
|
||||
_passCtrl.dispose();
|
||||
_pathCtrl.dispose();
|
||||
_ntfyCompletedCtrl.dispose();
|
||||
_ntfyFailedCtrl.dispose();
|
||||
_ntfyDrainedCtrl.dispose();
|
||||
_ntfyTokenCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final s = await WebDavSettings.load();
|
||||
final n = await NtfySettings.load();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_enabled = s.enabled;
|
||||
_urlCtrl.text = s.url;
|
||||
_userCtrl.text = s.username;
|
||||
_passCtrl.text = s.password;
|
||||
_pathCtrl.text = s.path;
|
||||
_status = s.lastStatus;
|
||||
_error = s.lastError;
|
||||
_lastSync = s.lastSyncAt;
|
||||
_ntfyEnabled = n.enabled;
|
||||
_ntfyCompletedCtrl.text = n.completedUrl;
|
||||
_ntfyFailedCtrl.text = n.failedUrl;
|
||||
_ntfyDrainedCtrl.text = n.drainedUrl;
|
||||
_ntfyTokenCtrl.text = n.authToken;
|
||||
_ntfyAttachment = n.attachmentMode;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
await WebDavSettings.save(
|
||||
enabled: _enabled,
|
||||
url: _urlCtrl.text,
|
||||
username: _userCtrl.text,
|
||||
password: _passCtrl.text,
|
||||
path: _pathCtrl.text,
|
||||
);
|
||||
await NtfySettings.save(
|
||||
enabled: _ntfyEnabled,
|
||||
completedUrl: _ntfyCompletedCtrl.text,
|
||||
failedUrl: _ntfyFailedCtrl.text,
|
||||
drainedUrl: _ntfyDrainedCtrl.text,
|
||||
attachmentMode: _ntfyAttachment,
|
||||
authToken: _ntfyTokenCtrl.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Settings saved')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _test() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await _save();
|
||||
final msg = await ref.read(webDavSyncProvider).testConnection();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = msg;
|
||||
_error = null;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = 'Test failed';
|
||||
_error = e.toString();
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Test failed: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncNow() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await _save();
|
||||
await ref.read(webDavSyncProvider).sync();
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sync finished')),
|
||||
);
|
||||
} catch (e) {
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Sync failed: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testNtfy(String kind, TextEditingController ctrl) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await _save();
|
||||
await ref.read(ntfyNotifierProvider).testTopic(ctrl.text, kind: kind);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Test notification sent')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('ntfy test failed: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _ntfyUrlRow({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String kind,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: 'https://ntfy.sh/mytopic',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
enabled: _ntfyEnabled,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: !_ntfyEnabled || _busy
|
||||
? null
|
||||
: () => _testNtfy(kind, controller),
|
||||
child: const Text('Test'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final direction = ref.watch(readingDirectionProvider);
|
||||
if (_loading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _save,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Reader', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Fallback page direction when a title has no saved preference '
|
||||
'(ComicLand). MangaRead / MangaTown default to Right → Left; '
|
||||
'overrides are saved per title and sync via WebDAV.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<ReadingDirection>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ReadingDirection.ltr,
|
||||
label: Text('Left → Right'),
|
||||
icon: Icon(Icons.format_textdirection_l_to_r),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ReadingDirection.rtl,
|
||||
label: Text('Right → Left'),
|
||||
icon: Icon(Icons.format_textdirection_r_to_l),
|
||||
),
|
||||
],
|
||||
selected: {direction},
|
||||
onSelectionChanged: (s) {
|
||||
ref.read(readingDirectionProvider.notifier).setDirection(s.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('WebDAV sync', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Syncs reading progress, reader mode/direction, Library stars, '
|
||||
'and History across devices.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Enable sync'),
|
||||
value: _enabled,
|
||||
onChanged: (v) => setState(() => _enabled = v),
|
||||
),
|
||||
TextField(
|
||||
controller: _urlCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'WebDAV URL',
|
||||
hintText: 'https://dav.example.com/remote.php/dav/files/user',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
),
|
||||
TextField(
|
||||
controller: _userCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Username'),
|
||||
autocorrect: false,
|
||||
),
|
||||
TextField(
|
||||
controller: _passCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
),
|
||||
TextField(
|
||||
controller: _pathCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Remote file path',
|
||||
hintText: WebDavSettings.defaultPath,
|
||||
),
|
||||
autocorrect: false,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_status != null)
|
||||
Text('Status: $_status', style: Theme.of(context).textTheme.bodySmall),
|
||||
if (_lastSync != null)
|
||||
Text(
|
||||
'Last sync: ${_lastSync!.toLocal()}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_error != null)
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _test,
|
||||
child: const Text('Test connection'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _busy || !_enabled ? null : _syncNow,
|
||||
child: const Text('Sync now'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Notifications (ntfy)',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Send download events to ntfy topics. Leave a URL empty to disable that event.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Enable notifications'),
|
||||
value: _ntfyEnabled,
|
||||
onChanged: (v) => setState(() => _ntfyEnabled = v),
|
||||
),
|
||||
_ntfyUrlRow(
|
||||
label: 'Chapter completed URL',
|
||||
controller: _ntfyCompletedCtrl,
|
||||
kind: 'completed',
|
||||
),
|
||||
_ntfyUrlRow(
|
||||
label: 'Job failed URL',
|
||||
controller: _ntfyFailedCtrl,
|
||||
kind: 'failed',
|
||||
),
|
||||
_ntfyUrlRow(
|
||||
label: 'Queue drained URL',
|
||||
controller: _ntfyDrainedCtrl,
|
||||
kind: 'drained',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Attachment on complete',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownMenu<NtfyAttachmentMode>(
|
||||
initialSelection: _ntfyAttachment,
|
||||
enabled: _ntfyEnabled,
|
||||
width: double.infinity,
|
||||
dropdownMenuEntries: [
|
||||
for (final m in NtfyAttachmentMode.values)
|
||||
DropdownMenuEntry(value: m, label: m.label),
|
||||
],
|
||||
onSelected: !_ntfyEnabled
|
||||
? null
|
||||
: (v) {
|
||||
if (v != null) setState(() => _ntfyAttachment = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Cover uses the series cover URL. First page is resized to ≤720px / ≤250 KB. CBZ skips if over 15 MB.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
TextField(
|
||||
controller: _ntfyTokenCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Auth token (optional)',
|
||||
hintText: 'Bearer token for private topics',
|
||||
),
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enabled: _ntfyEnabled,
|
||||
),
|
||||
if (_busy) ...[
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
Text('About', style: Theme.of(context).textTheme.titleMedium),
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Version'),
|
||||
subtitle: Text('1.0'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user