init
This commit is contained in:
32
app/lib/screens/home_screen.dart
Normal file
32
app/lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'sms_list_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: const [SmsListScreen(), SettingsScreen()],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (i) => setState(() => _index = i),
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.sms_outlined), selectedIcon: Icon(Icons.sms), label: 'Messages'),
|
||||
NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
121
app/lib/screens/login_screen.dart
Normal file
121
app/lib/screens/login_screen.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _serverCtrl = TextEditingController(text: 'http://192.168.1.100:3000');
|
||||
final _userCtrl = TextEditingController();
|
||||
final _passCtrl = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _submit(bool register) async {
|
||||
setState(() { _loading = true; _error = null; });
|
||||
|
||||
final url = _serverCtrl.text.trim();
|
||||
final user = _userCtrl.text.trim();
|
||||
final pass = _passCtrl.text.trim();
|
||||
|
||||
try {
|
||||
apiService.configure(url, '');
|
||||
final token = register
|
||||
? await apiService.register(user, pass)
|
||||
: await apiService.login(user, pass);
|
||||
apiService.configure(url, token);
|
||||
await settings.setServerUrl(url);
|
||||
await settings.setToken(token);
|
||||
await apiService.registerDevice('Android');
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
|
||||
}
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.sms_outlined, size: 64, color: colors.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text('SMS Monitor', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _serverCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'http://your-server:3000',
|
||||
prefixIcon: Icon(Icons.dns_outlined),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _userCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Username',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _submit(false),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: TextStyle(color: colors.error)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _loading ? null : () => _submit(true),
|
||||
child: const Text('Register'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _loading ? null : () => _submit(false),
|
||||
child: _loading
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Sign In'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
app/lib/screens/settings_screen.dart
Normal file
76
app/lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../main.dart';
|
||||
import 'login_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Server', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text('URL: ${settings.serverUrl ?? "Not set"}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant)),
|
||||
Text('Token: ${settings.token != null ? "Configured" : "Missing"}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Upload Mode', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
title: const Text('Auto Upload'),
|
||||
subtitle: const Text('Automatically upload new incoming SMS'),
|
||||
value: settings.autoUpload,
|
||||
onChanged: (v) => settings.setAutoUpload(v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
settings.setToken('');
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(_) => false,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Logout'),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: colors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
229
app/lib/screens/sms_list_screen.dart
Normal file
229
app/lib/screens/sms_list_screen.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../models/sms_message.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class SmsListScreen extends StatefulWidget {
|
||||
const SmsListScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SmsListScreen> createState() => _SmsListScreenState();
|
||||
}
|
||||
|
||||
class _SmsListScreenState extends State<SmsListScreen> {
|
||||
List<SmsMessage> _messages = [];
|
||||
final _selected = <int>{};
|
||||
Set<String> _uploadedKeys = {};
|
||||
bool _loading = false;
|
||||
bool _hasSmsPermission = false;
|
||||
StreamSubscription<SmsMessage>? _smsSub;
|
||||
|
||||
static String _dedupKey(SmsMessage m) => '${m.phoneNumber}|${m.content}|${m.smsDate}';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_uploadedKeys = settings.getUploadedKeys();
|
||||
_requestPermission();
|
||||
}
|
||||
|
||||
Future<void> _requestPermission() async {
|
||||
final status = await Permission.sms.request();
|
||||
if (mounted) {
|
||||
setState(() => _hasSmsPermission = status.isGranted);
|
||||
if (status.isGranted) _loadSms();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSms() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
_messages = await smsReaderService.queryAllSms();
|
||||
} catch (e) { /* ignore */ }
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
|
||||
bool _isUploaded(SmsMessage m) => _uploadedKeys.contains(_dedupKey(m));
|
||||
|
||||
Future<void> _uploadSelected() async {
|
||||
if (_selected.isEmpty) return;
|
||||
final toUpload = _messages
|
||||
.asMap()
|
||||
.entries
|
||||
.where((e) => _selected.contains(e.key) && !_isUploaded(e.value))
|
||||
.map((e) => e.value)
|
||||
.toList();
|
||||
if (toUpload.isEmpty) return;
|
||||
try {
|
||||
await apiService.uploadSms(toUpload);
|
||||
final newKeys = toUpload.map(_dedupKey).toList();
|
||||
settings.addUploadedKeys(newKeys);
|
||||
_uploadedKeys = settings.getUploadedKeys();
|
||||
_selected.clear();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Uploaded ${toUpload.length} messages')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Upload failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadAll() async {
|
||||
final toUpload = _messages.where((m) => !_isUploaded(m)).toList();
|
||||
if (toUpload.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No new messages to upload')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final count = await apiService.uploadSms(toUpload);
|
||||
final newKeys = toUpload.map(_dedupKey).toList();
|
||||
settings.addUploadedKeys(newKeys);
|
||||
_uploadedKeys = settings.getUploadedKeys();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Uploaded $count messages')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Upload failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleAutoUpload() {
|
||||
final newVal = !settings.autoUpload;
|
||||
settings.setAutoUpload(newVal);
|
||||
if (newVal) {
|
||||
_startAutoUpload();
|
||||
} else {
|
||||
_smsSub?.cancel();
|
||||
_smsSub = null;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _startAutoUpload() {
|
||||
_smsSub = smsReaderService.listenToIncoming().listen((sms) async {
|
||||
try {
|
||||
await apiService.uploadSms([sms]);
|
||||
settings.addUploadedKey(_dedupKey(sms));
|
||||
_uploadedKeys = settings.getUploadedKeys();
|
||||
_messages.insert(0, sms);
|
||||
if (mounted) setState(() {});
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_smsSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Messages'),
|
||||
actions: [
|
||||
if (_selected.isNotEmpty)
|
||||
IconButton(icon: const Icon(Icons.cloud_upload), onPressed: _uploadSelected, tooltip: 'Upload selected'),
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _hasSmsPermission ? _loadSms : _requestPermission),
|
||||
],
|
||||
),
|
||||
body: _hasSmsPermission == false
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('SMS permission required'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: _requestPermission, child: const Text('Grant')),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _messages.isEmpty
|
||||
? const Center(child: Text('No messages'))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadSms,
|
||||
child: ListView.builder(
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (_, i) {
|
||||
final m = _messages[i];
|
||||
final sel = _selected.contains(i);
|
||||
final uploaded = _isUploaded(m);
|
||||
return ListTile(
|
||||
selected: sel,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: sel ? colors.primaryContainer : colors.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
uploaded ? Icons.cloud_done : (m.type == 'received' ? Icons.arrow_downward : Icons.arrow_upward),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
m.contactName ?? m.phoneNumber,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(m.content, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: uploaded
|
||||
? Icon(Icons.cloud_done, color: colors.primary, size: 20)
|
||||
: IconButton(
|
||||
icon: Icon(sel ? Icons.check_circle : Icons.circle_outlined),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
sel ? _selected.remove(i) : _selected.add(i);
|
||||
});
|
||||
},
|
||||
),
|
||||
onTap: uploaded
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
sel ? _selected.remove(i) : _selected.add(i);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'auto',
|
||||
onPressed: _toggleAutoUpload,
|
||||
backgroundColor: settings.autoUpload ? colors.errorContainer : colors.surfaceContainerHighest,
|
||||
child: Icon(settings.autoUpload ? Icons.sync_disabled : Icons.sync, color: colors.onPrimaryContainer),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FloatingActionButton(
|
||||
heroTag: 'upload',
|
||||
onPressed: _uploadAll,
|
||||
tooltip: 'Upload all',
|
||||
child: const Icon(Icons.cloud_upload),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user