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:
2026-08-29 17:50:31 +07:00
parent 6e7f1ced35
commit a7c6058540
105 changed files with 16721 additions and 275 deletions
+397
View File
@@ -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'),
),
],
),
),
);
}
}