This commit is contained in:
wuxu
2026-04-28 17:34:03 +08:00
commit 80ee99e564
43 changed files with 6330 additions and 0 deletions

52
app/lib/main.dart Normal file
View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'services/settings_service.dart';
import 'services/api_service.dart';
import 'services/sms_reader_service.dart';
import 'screens/login_screen.dart';
import 'screens/home_screen.dart';
final apiService = ApiService();
final smsReaderService = SmsReaderService();
late SettingsService settings;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
settings = await SettingsService.init();
if (settings.isConfigured) {
apiService.configure(settings.serverUrl!, settings.token!);
}
runApp(const SmsMonitorApp());
}
class SmsMonitorApp extends StatelessWidget {
const SmsMonitorApp({super.key});
@override
Widget build(BuildContext context) {
return DynamicColorBuilder(
builder: (ColorScheme? dynamicScheme) {
final scheme = dynamicScheme?.harmonized() ??
ColorScheme.fromSeed(seedColor: Colors.indigo, brightness: Brightness.light);
return MaterialApp(
title: 'SMS Monitor',
theme: ThemeData(
colorScheme: scheme,
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: dynamicScheme?.harmonized() ??
ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: settings.isConfigured ? const HomeScreen() : const LoginScreen(),
);
},
);
}
}

View File

@@ -0,0 +1,34 @@
class SmsMessage {
final int? id;
final String phoneNumber;
final String? contactName;
final String content;
final String type; // 'received' | 'sent'
final String smsDate;
SmsMessage({
this.id,
required this.phoneNumber,
this.contactName,
required this.content,
required this.type,
required this.smsDate,
});
Map<String, dynamic> toJson() => {
'phone_number': phoneNumber,
'contact_name': contactName,
'content': content,
'type': type,
'sms_date': smsDate,
};
factory SmsMessage.fromJson(Map<String, dynamic> json) => SmsMessage(
id: json['id'],
phoneNumber: json['phone_number'],
contactName: json['contact_name'],
content: json['content'],
type: json['type'],
smsDate: json['sms_date'],
);
}

View 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'),
],
),
);
}
}

View 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'),
),
),
],
),
],
),
),
),
),
);
}
}

View 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),
),
),
],
),
),
);
}
}

View 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),
),
],
),
);
}
}

View File

@@ -0,0 +1,64 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/sms_message.dart';
class ApiService {
String _baseUrl = '';
String? _token;
void configure(String baseUrl, String token) {
_baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
_token = token;
}
Map<String, String> get _headers => {
'Content-Type': 'application/json',
if (_token != null) 'Authorization': 'Bearer $_token',
};
Future<String> login(String username, String password) async {
final res = await http.post(
Uri.parse('$_baseUrl/api/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
);
if (res.statusCode != 200) throw Exception(_error(res));
return jsonDecode(res.body)['token'];
}
Future<String> register(String username, String password) async {
final res = await http.post(
Uri.parse('$_baseUrl/api/auth/register'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
);
if (res.statusCode != 201) throw Exception(_error(res));
return jsonDecode(res.body)['token'];
}
Future<int> uploadSms(List<SmsMessage> messages) async {
final res = await http.post(
Uri.parse('$_baseUrl/api/sms/upload'),
headers: _headers,
body: jsonEncode(messages.map((m) => m.toJson()).toList()),
);
if (res.statusCode != 200) throw Exception(_error(res));
return jsonDecode(res.body)['uploaded'];
}
Future<void> registerDevice(String deviceName) async {
await http.post(
Uri.parse('$_baseUrl/api/devices/register'),
headers: _headers,
body: jsonEncode({'device_name': deviceName}),
);
}
String _error(http.Response res) {
try {
return jsonDecode(res.body)['error'] ?? 'Request failed';
} catch (_) {
return 'Request failed (${res.statusCode})';
}
}
}

View File

@@ -0,0 +1,43 @@
import 'package:shared_preferences/shared_preferences.dart';
class SettingsService {
static const _keyServerUrl = 'server_url';
static const _keyToken = 'token';
static const _keyAutoUpload = 'auto_upload';
static const _keyUploaded = 'uploaded_keys';
final SharedPreferences _prefs;
SettingsService(this._prefs);
String? get serverUrl => _prefs.getString(_keyServerUrl);
Future<bool> setServerUrl(String v) => _prefs.setString(_keyServerUrl, v);
String? get token => _prefs.getString(_keyToken);
Future<bool> setToken(String v) => _prefs.setString(_keyToken, v);
bool get autoUpload => _prefs.getBool(_keyAutoUpload) ?? false;
Future<bool> setAutoUpload(bool v) => _prefs.setBool(_keyAutoUpload, v);
bool get isConfigured => serverUrl != null && token != null;
Set<String> getUploadedKeys() => _prefs.getStringList(_keyUploaded)?.toSet() ?? <String>{};
Future<bool> addUploadedKey(String key) {
final keys = _prefs.getStringList(_keyUploaded) ?? [];
keys.add(key);
return _prefs.setStringList(_keyUploaded, keys);
}
Future<bool> addUploadedKeys(List<String> newKeys) {
final keys = _prefs.getStringList(_keyUploaded) ?? [];
keys.addAll(newKeys);
return _prefs.setStringList(_keyUploaded, keys);
}
static Future<SettingsService> init() async {
final prefs = await SharedPreferences.getInstance();
return SettingsService(prefs);
}
}

View File

@@ -0,0 +1,36 @@
import 'dart:async';
import 'package:sms_maintained/sms_maintained.dart';
import '../models/sms_message.dart';
class SmsReaderService {
Future<List<SmsMessage>> queryAllSms() async {
final messages = <SmsMessage>[];
final all = await SmsQuery().querySms(
kinds: [SmsQueryKind.Inbox, SmsQueryKind.Sent],
);
for (final s in all) {
messages.add(SmsMessage(
phoneNumber: s.address ?? '',
contactName: s.sender ?? s.address,
content: s.body ?? '',
type: s.kind == SmsQueryKind.Sent ? 'sent' : 'received',
smsDate: DateTime.fromMillisecondsSinceEpoch(s.date!).toUtc().toIso8601String(),
));
}
return messages;
}
Stream<SmsMessage> listenToIncoming() {
final controller = StreamController<SmsMessage>.broadcast();
SmsReceiver().onSmsReceived!.listen((SmsMessage sms) {
controller.add(SmsMessage(
phoneNumber: sms.address ?? '',
contactName: sms.sender ?? sms.address,
content: sms.body ?? '',
type: 'received',
smsDate: DateTime.now().toUtc().toIso8601String(),
));
});
return controller.stream;
}
}