ChatServer
This commit is contained in:
parent
3ddd56760a
commit
a569f54b0b
14 changed files with 937 additions and 11 deletions
28
README.md
28
README.md
|
|
@ -85,6 +85,34 @@
|
|||
|
||||
---
|
||||
|
||||
## 母艦「お局様」LAN サーバの起動
|
||||
|
||||
1. Dart/Flutter SDK が入った Linux / Android(Termux 等)端末でリポジトリを取得
|
||||
2. 監視サーバを起動
|
||||
```bash
|
||||
dart run bin/mothership_server.dart
|
||||
```
|
||||
- 環境変数 `MOTHERSHIP_HOST`, `MOTHERSHIP_PORT`, `MOTHERSHIP_API_KEY`, `MOTHERSHIP_DATA_DIR` で上書き可能
|
||||
- 既定値: `0.0.0.0:8787`, API キー `TEST_MOTHERSHIP_KEY`, 保存先 `data/mothership`
|
||||
- `data/mothership/status.json` に各クライアントの心拍/ハッシュを保存
|
||||
3. ブラウザで `http://<host>:<port>/` を開くとステータス一覧を閲覧できます(CUI 常駐で OK)
|
||||
|
||||
### クライアント(販売アシスト1号)からの接続設定
|
||||
|
||||
1. アプリの `S1:設定` → 「外部同期(母艦システム『お局様』連携)」で以下を入力
|
||||
- ホストドメイン: `http://192.168.0.10:8787` のようにプロトコル付きで指定
|
||||
- パスワード: サーバ側 API キー(例: `TEST_MOTHERSHIP_KEY`)
|
||||
2. 保存するとアプリ起動時に `POST /sync/heartbeat` が自動送信され、寿命残時間が母艦に表示されます。
|
||||
3. 同じ設定でチャット送受信・ハッシュ送信が有効になります(下記参照)。
|
||||
|
||||
### チャット同期(最小構成)
|
||||
|
||||
- Flutter アプリ側では 10 秒間隔の軽量ポーリングをバックグラウンドで実行し、`/chat/send` / `/chat/pending` / `/chat/ack` とローカル SQLite を同期します。
|
||||
- 設定画面からチャット画面を開かなくても新着が取り込まれ、開いた瞬間に最新ログが表示されます。
|
||||
- 端末がスリープに入るとポーリングを停止し、アプリが前面に戻ったタイミングで即時同期→再開します。
|
||||
|
||||
---
|
||||
|
||||
## 更新ポリシー
|
||||
|
||||
- README は **機能追加・アーキテクチャ変更・モジュール構成の見直し時に必ず更新** します。
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:h_1/mothership/chat_store.dart';
|
||||
import 'package:h_1/mothership/config.dart';
|
||||
import 'package:h_1/mothership/data_store.dart';
|
||||
import 'package:h_1/mothership/server.dart';
|
||||
|
|
@ -8,7 +9,9 @@ Future<void> main(List<String> args) async {
|
|||
final config = MothershipConfig.fromEnv();
|
||||
final dataStore = MothershipDataStore(config.dataDirectory);
|
||||
await dataStore.init();
|
||||
final server = MothershipServer(config: config, dataStore: dataStore);
|
||||
final chatStore = MothershipChatStore(config.dataDirectory);
|
||||
await chatStore.init();
|
||||
final server = MothershipServer(config: config, dataStore: dataStore, chatStore: chatStore);
|
||||
final httpServer = await server.start();
|
||||
stdout.writeln('Mothership listening on http://${config.host}:${config.port}');
|
||||
ProcessSignal.sigint.watch().listen((_) async {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// lib/main.dart
|
||||
// version: 1.5.02 (Update: Date selection & Tax fix)
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
|
|
@ -12,6 +14,8 @@ import 'screens/dashboard_screen.dart'; // ダッシュボード
|
|||
import 'services/location_service.dart'; // 位置情報サービス
|
||||
import 'services/customer_repository.dart'; // 顧客リポジトリ
|
||||
import 'services/app_settings_repository.dart';
|
||||
import 'services/chat_sync_scheduler.dart';
|
||||
import 'services/mothership_client.dart';
|
||||
import 'services/theme_controller.dart';
|
||||
import 'utils/build_expiry_info.dart';
|
||||
|
||||
|
|
@ -23,11 +27,13 @@ void main() async {
|
|||
runApp(ExpiredApp(expiryInfo: expiryInfo));
|
||||
return;
|
||||
}
|
||||
runApp(const MyApp());
|
||||
runApp(MyApp(expiryInfo: expiryInfo));
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({super.key});
|
||||
const MyApp({super.key, required this.expiryInfo});
|
||||
|
||||
final BuildExpiryInfo expiryInfo;
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
|
|
@ -36,6 +42,25 @@ class MyApp extends StatefulWidget {
|
|||
class _MyAppState extends State<MyApp> {
|
||||
final TransformationController _zoomController = TransformationController();
|
||||
int _activePointers = 0;
|
||||
final MothershipClient _mothershipClient = MothershipClient();
|
||||
final ChatSyncScheduler _chatSyncScheduler = ChatSyncScheduler();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sendHeartbeat();
|
||||
_chatSyncScheduler.start();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chatSyncScheduler.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sendHeartbeat() {
|
||||
Future.microtask(() => _mothershipClient.sendHeartbeat(widget.expiryInfo));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
|||
45
lib/models/chat_message.dart
Normal file
45
lib/models/chat_message.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
enum ChatDirection { outbound, inbound }
|
||||
|
||||
class ChatMessage {
|
||||
ChatMessage({
|
||||
this.id,
|
||||
required this.messageId,
|
||||
required this.clientId,
|
||||
required this.direction,
|
||||
required this.body,
|
||||
required this.createdAt,
|
||||
this.synced = true,
|
||||
this.deliveredAt,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final String messageId;
|
||||
final String clientId;
|
||||
final ChatDirection direction;
|
||||
final String body;
|
||||
final DateTime createdAt;
|
||||
final bool synced;
|
||||
final DateTime? deliveredAt;
|
||||
|
||||
ChatMessage copyWith({
|
||||
int? id,
|
||||
String? messageId,
|
||||
String? clientId,
|
||||
ChatDirection? direction,
|
||||
String? body,
|
||||
DateTime? createdAt,
|
||||
bool? synced,
|
||||
DateTime? deliveredAt,
|
||||
}) {
|
||||
return ChatMessage(
|
||||
id: id ?? this.id,
|
||||
messageId: messageId ?? this.messageId,
|
||||
clientId: clientId ?? this.clientId,
|
||||
direction: direction ?? this.direction,
|
||||
body: body ?? this.body,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
synced: synced ?? this.synced,
|
||||
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
97
lib/mothership/chat_store.dart
Normal file
97
lib/mothership/chat_store.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
class ChatEnvelope {
|
||||
ChatEnvelope({required this.messageId, required this.body, required this.createdAt});
|
||||
|
||||
final String messageId;
|
||||
final String body;
|
||||
final DateTime createdAt;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'messageId': messageId,
|
||||
'body': body,
|
||||
'createdAt': createdAt.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
factory ChatEnvelope.fromJson(Map<String, dynamic> json) {
|
||||
return ChatEnvelope(
|
||||
messageId: json['messageId'] as String,
|
||||
body: json['body'] as String,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(json['createdAt'] as int, isUtc: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MothershipChatStore {
|
||||
MothershipChatStore(this.rootDir);
|
||||
|
||||
final Directory rootDir;
|
||||
|
||||
Future<void> init() async {
|
||||
if (!await rootDir.exists()) {
|
||||
await rootDir.create(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> appendInbound(String clientId, List<ChatEnvelope> messages) async {
|
||||
if (messages.isEmpty) return;
|
||||
final file = await _logFile(clientId);
|
||||
final sink = file.openWrite(mode: FileMode.append);
|
||||
for (final message in messages) {
|
||||
sink.writeln(jsonEncode(message.toJson()));
|
||||
}
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
}
|
||||
|
||||
Future<List<ChatEnvelope>> pendingOutbound(String clientId) async {
|
||||
final file = await _outboxFile(clientId);
|
||||
if (!await file.exists()) return [];
|
||||
try {
|
||||
final raw = await file.readAsString();
|
||||
if (raw.trim().isEmpty) return [];
|
||||
final decoded = jsonDecode(raw) as List<dynamic>;
|
||||
return decoded.map((e) => ChatEnvelope.fromJson(Map<String, dynamic>.from(e as Map))).toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enqueueOutbound(String clientId, List<ChatEnvelope> messages) async {
|
||||
if (messages.isEmpty) return;
|
||||
final current = await pendingOutbound(clientId);
|
||||
final combined = [...current, ...messages];
|
||||
final file = await _outboxFile(clientId);
|
||||
await file.writeAsString(jsonEncode(combined.map((e) => e.toJson()).toList()));
|
||||
}
|
||||
|
||||
Future<void> acknowledge(String clientId, List<String> messageIds) async {
|
||||
if (messageIds.isEmpty) return;
|
||||
final file = await _outboxFile(clientId);
|
||||
if (!await file.exists()) return;
|
||||
final current = await pendingOutbound(clientId);
|
||||
final filtered = current.where((m) => !messageIds.contains(m.messageId)).toList();
|
||||
if (filtered.isEmpty) {
|
||||
await file.delete();
|
||||
} else {
|
||||
await file.writeAsString(jsonEncode(filtered.map((e) => e.toJson()).toList()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _logFile(String clientId) async {
|
||||
final dir = Directory('${rootDir.path}/$clientId');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return File('${dir.path}/log.jsonl');
|
||||
}
|
||||
|
||||
Future<File> _outboxFile(String clientId) async {
|
||||
final dir = Directory('${rootDir.path}/$clientId');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return File('${dir.path}/outbox.json');
|
||||
}
|
||||
}
|
||||
|
|
@ -5,19 +5,24 @@ import 'package:shelf/shelf.dart';
|
|||
import 'package:shelf/shelf_io.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
import 'chat_store.dart';
|
||||
import 'config.dart';
|
||||
import 'data_store.dart';
|
||||
|
||||
class MothershipServer {
|
||||
MothershipServer({required this.config, required this.dataStore});
|
||||
MothershipServer({required this.config, required this.dataStore, required this.chatStore});
|
||||
|
||||
final MothershipConfig config;
|
||||
final MothershipDataStore dataStore;
|
||||
final MothershipChatStore chatStore;
|
||||
|
||||
Future<HttpServer> start() async {
|
||||
final router = Router()
|
||||
..post('/sync/heartbeat', _handleHeartbeat)
|
||||
..post('/sync/hash', _handleHash)
|
||||
..post('/chat/send', _handleChatSend)
|
||||
..get('/chat/pending', _handleChatPending)
|
||||
..post('/chat/ack', _handleChatAck)
|
||||
..get('/status', _handleStatus)
|
||||
..get('/', _handleDashboard);
|
||||
|
||||
|
|
@ -72,6 +77,48 @@ class MothershipServer {
|
|||
return Response.ok('ok');
|
||||
}
|
||||
|
||||
Future<Response> _handleChatSend(Request request) async {
|
||||
final body = await request.readAsString();
|
||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||
final clientId = json['clientId'] as String?;
|
||||
if (clientId == null || clientId.isEmpty) {
|
||||
return Response(400, body: 'clientId is required');
|
||||
}
|
||||
final messages = (json['messages'] as List?) ?? [];
|
||||
final envelopes = messages
|
||||
.whereType<Map>()
|
||||
.map((e) => ChatEnvelope(
|
||||
messageId: e['messageId'] as String,
|
||||
body: e['body'] as String,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch((e['createdAt'] as int?) ?? 0, isUtc: true),
|
||||
))
|
||||
.toList();
|
||||
await chatStore.appendInbound(clientId, envelopes);
|
||||
return Response.ok(jsonEncode({'stored': envelopes.length}), headers: {'content-type': 'application/json'});
|
||||
}
|
||||
|
||||
Future<Response> _handleChatPending(Request request) async {
|
||||
final clientId = request.url.queryParameters['clientId'];
|
||||
if (clientId == null || clientId.isEmpty) {
|
||||
return Response(400, body: 'clientId is required');
|
||||
}
|
||||
final messages = await chatStore.pendingOutbound(clientId);
|
||||
final payload = {'messages': messages.map((e) => e.toJson()).toList()};
|
||||
return Response.ok(jsonEncode(payload), headers: {'content-type': 'application/json'});
|
||||
}
|
||||
|
||||
Future<Response> _handleChatAck(Request request) async {
|
||||
final body = await request.readAsString();
|
||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||
final clientId = json['clientId'] as String?;
|
||||
if (clientId == null || clientId.isEmpty) {
|
||||
return Response(400, body: 'clientId is required');
|
||||
}
|
||||
final delivered = (json['delivered'] as List?)?.cast<String>() ?? [];
|
||||
await chatStore.acknowledge(clientId, delivered);
|
||||
return Response.ok('ok');
|
||||
}
|
||||
|
||||
Future<Response> _handleStatus(Request request) async {
|
||||
final status = dataStore.listStatuses().map((e) => e.toJson()).toList();
|
||||
return Response.ok(jsonEncode({'clients': status}), headers: {'content-type': 'application/json'});
|
||||
|
|
|
|||
208
lib/screens/chat_screen.dart
Normal file
208
lib/screens/chat_screen.dart
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/chat_message.dart';
|
||||
import '../services/chat_repository.dart';
|
||||
import '../services/mothership_chat_client.dart';
|
||||
import '../services/mothership_client.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final ChatRepository _repository = ChatRepository();
|
||||
late final MothershipClient _mothershipClient;
|
||||
late final MothershipChatClient _chatClient;
|
||||
final TextEditingController _inputController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
bool _syncing = false;
|
||||
bool _sending = false;
|
||||
List<ChatMessage> _messages = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mothershipClient = MothershipClient();
|
||||
_chatClient = MothershipChatClient(repository: _repository, baseClient: _mothershipClient);
|
||||
_refreshMessages();
|
||||
}
|
||||
|
||||
Future<void> _refreshMessages() async {
|
||||
setState(() => _syncing = true);
|
||||
await _chatClient.sync();
|
||||
final list = await _repository.listMessages();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = list;
|
||||
_syncing = false;
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_scrollController.hasClients) return;
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
if (_sending) return;
|
||||
final text = _inputController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
setState(() => _sending = true);
|
||||
final clientId = await _mothershipClient.ensureClientId();
|
||||
await _repository.addOutbound(clientId: clientId, body: text);
|
||||
_inputController.clear();
|
||||
await _chatClient.sync();
|
||||
final list = await _repository.listMessages();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = list;
|
||||
_sending = false;
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('母艦チャット'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '再同期',
|
||||
onPressed: _syncing ? null : _refreshMessages,
|
||||
icon: _syncing ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: theme.colorScheme.surface,
|
||||
child: _messages.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_syncing ? '同期中...' : 'まだメッセージはありません',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) => _MessageBubble(message: _messages[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'メッセージを入力',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _sending ? null : _sendMessage,
|
||||
icon: const Icon(Icons.send),
|
||||
label: const Text('送信'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
const _MessageBubble({required this.message});
|
||||
|
||||
final ChatMessage message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOutbound = message.direction == ChatDirection.outbound;
|
||||
final theme = Theme.of(context);
|
||||
final bubbleColor = isOutbound ? theme.colorScheme.primary : Colors.grey.shade200;
|
||||
final textColor = isOutbound ? Colors.white : Colors.grey.shade900;
|
||||
final align = isOutbound ? CrossAxisAlignment.end : CrossAxisAlignment.start;
|
||||
final borderRadius = BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isOutbound ? 16 : 4),
|
||||
bottomRight: Radius.circular(isOutbound ? 4 : 16),
|
||||
);
|
||||
final timeText = TimeOfDay.fromDateTime(message.createdAt.toLocal()).format(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: align,
|
||||
children: [
|
||||
Align(
|
||||
alignment: isOutbound ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: borderRadius,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: align,
|
||||
children: [
|
||||
Text(
|
||||
message.body,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: textColor),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
timeText,
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: textColor.withOpacity(0.8), fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../services/app_settings_repository.dart';
|
||||
import '../services/theme_controller.dart';
|
||||
import 'company_info_screen.dart';
|
||||
import 'email_settings_screen.dart';
|
||||
import 'business_profile_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
|
@ -427,10 +429,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')),
|
||||
TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('保存'),
|
||||
onPressed: _saveExternalSync,
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('保存'),
|
||||
onPressed: _saveExternalSync,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.chat_bubble_outline),
|
||||
label: const Text('チャットを開く'),
|
||||
onPressed: () async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const ChatScreen()));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
88
lib/services/chat_repository.dart
Normal file
88
lib/services/chat_repository.dart
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/chat_message.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class ChatRepository {
|
||||
ChatRepository();
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final _uuid = const Uuid();
|
||||
|
||||
Future<Database> _db() => _dbHelper.database;
|
||||
|
||||
Future<List<ChatMessage>> listMessages({int limit = 200}) async {
|
||||
final db = await _db();
|
||||
final rows = await db.query(
|
||||
'chat_messages',
|
||||
orderBy: 'created_at DESC',
|
||||
limit: limit,
|
||||
);
|
||||
return rows.map(_fromRow).toList().reversed.toList();
|
||||
}
|
||||
|
||||
Future<void> addOutbound({required String clientId, required String body}) async {
|
||||
final db = await _db();
|
||||
final now = DateTime.now().toUtc();
|
||||
await db.insert(
|
||||
'chat_messages',
|
||||
{
|
||||
'message_id': _uuid.v4(),
|
||||
'client_id': clientId,
|
||||
'direction': 'outbound',
|
||||
'body': body,
|
||||
'created_at': now.millisecondsSinceEpoch,
|
||||
'synced': 0,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> upsertInbound(ChatMessage message) async {
|
||||
final db = await _db();
|
||||
await db.insert(
|
||||
'chat_messages',
|
||||
{
|
||||
'message_id': message.messageId,
|
||||
'client_id': message.clientId,
|
||||
'direction': 'inbound',
|
||||
'body': message.body,
|
||||
'created_at': message.createdAt.millisecondsSinceEpoch,
|
||||
'synced': 1,
|
||||
'delivered_at': DateTime.now().toUtc().millisecondsSinceEpoch,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ChatMessage>> pendingOutbound() async {
|
||||
final db = await _db();
|
||||
final rows = await db.query('chat_messages', where: 'direction = ? AND synced = 0', whereArgs: ['outbound'], orderBy: 'created_at ASC');
|
||||
return rows.map(_fromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> markSynced(List<String> messageIds) async {
|
||||
if (messageIds.isEmpty) return;
|
||||
final db = await _db();
|
||||
await db.update(
|
||||
'chat_messages',
|
||||
{'synced': 1},
|
||||
where: 'message_id IN (${List.filled(messageIds.length, '?').join(',')})',
|
||||
whereArgs: messageIds,
|
||||
);
|
||||
}
|
||||
|
||||
ChatMessage _fromRow(Map<String, dynamic> row) {
|
||||
return ChatMessage(
|
||||
id: row['id'] as int?,
|
||||
messageId: row['message_id'] as String,
|
||||
clientId: row['client_id'] as String,
|
||||
direction: (row['direction'] as String) == 'outbound' ? ChatDirection.outbound : ChatDirection.inbound,
|
||||
body: row['body'] as String,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int, isUtc: true),
|
||||
synced: (row['synced'] as int? ?? 1) == 1,
|
||||
deliveredAt: row['delivered_at'] != null ? DateTime.fromMillisecondsSinceEpoch(row['delivered_at'] as int, isUtc: true) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/services/chat_sync_scheduler.dart
Normal file
68
lib/services/chat_sync_scheduler.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'mothership_chat_client.dart';
|
||||
|
||||
class ChatSyncScheduler with WidgetsBindingObserver {
|
||||
ChatSyncScheduler({Duration? interval}) : _interval = interval ?? const Duration(seconds: 10);
|
||||
|
||||
final Duration _interval;
|
||||
final MothershipChatClient _chatClient = MothershipChatClient();
|
||||
|
||||
Timer? _timer;
|
||||
bool _started = false;
|
||||
bool _syncing = false;
|
||||
bool _appActive = true;
|
||||
|
||||
void start() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
final binding = WidgetsBinding.instance;
|
||||
binding.addObserver(this);
|
||||
_appActive = _isActiveState(binding.lifecycleState);
|
||||
if (_appActive) {
|
||||
_scheduleImmediate();
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (!_started) return;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_started = false;
|
||||
}
|
||||
|
||||
void dispose() => stop();
|
||||
|
||||
void _scheduleImmediate() {
|
||||
_timer?.cancel();
|
||||
_runSync();
|
||||
_timer = Timer.periodic(_interval, (_) => _runSync());
|
||||
}
|
||||
|
||||
void _runSync() {
|
||||
if (!_appActive || _syncing) return;
|
||||
_syncing = true;
|
||||
unawaited(_chatClient.sync().whenComplete(() {
|
||||
_syncing = false;
|
||||
}));
|
||||
}
|
||||
|
||||
bool _isActiveState(AppLifecycleState? state) {
|
||||
return state == null || state == AppLifecycleState.resumed;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_appActive = _isActiveState(state);
|
||||
if (!_started) return;
|
||||
if (_appActive) {
|
||||
_scheduleImmediate();
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
|
|||
import 'package:path/path.dart';
|
||||
|
||||
class DatabaseHelper {
|
||||
static const _databaseVersion = 25;
|
||||
static const _databaseVersion = 26;
|
||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||
static Database? _database;
|
||||
|
||||
|
|
@ -195,6 +195,21 @@ class DatabaseHelper {
|
|||
await _safeAddColumn(db, 'invoices', 'meta_json TEXT');
|
||||
await _safeAddColumn(db, 'invoices', 'meta_hash TEXT');
|
||||
}
|
||||
if (oldVersion < 26) {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT UNIQUE NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
delivered_at INTEGER
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at)');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
|
|
@ -359,6 +374,20 @@ class DatabaseHelper {
|
|||
value TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT UNIQUE NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
delivered_at INTEGER
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX idx_chat_messages_created_at ON chat_messages(created_at)');
|
||||
}
|
||||
|
||||
Future<void> _safeAddColumn(Database db, String table, String columnDef) async {
|
||||
|
|
|
|||
120
lib/services/mothership_chat_client.dart
Normal file
120
lib/services/mothership_chat_client.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/chat_message.dart';
|
||||
import 'chat_repository.dart';
|
||||
import 'mothership_client.dart';
|
||||
|
||||
class MothershipChatClient {
|
||||
MothershipChatClient({ChatRepository? repository, MothershipClient? baseClient, http.Client? httpClient})
|
||||
: _repository = repository ?? ChatRepository(),
|
||||
_baseClient = baseClient ?? MothershipClient(),
|
||||
_httpClient = httpClient;
|
||||
|
||||
final ChatRepository _repository;
|
||||
final MothershipClient _baseClient;
|
||||
final http.Client? _httpClient;
|
||||
|
||||
Future<void> sync() async {
|
||||
await Future.wait([_pushPending(), _fetchInbound()]);
|
||||
}
|
||||
|
||||
Future<void> _pushPending() async {
|
||||
final config = await _baseClient.loadConfig();
|
||||
if (config == null) {
|
||||
debugPrint('[ChatSync] skip push: config missing');
|
||||
return;
|
||||
}
|
||||
final clientId = await _baseClient.ensureClientId();
|
||||
final pending = await _repository.pendingOutbound();
|
||||
if (pending.isEmpty) return;
|
||||
final client = _httpClient ?? http.Client();
|
||||
try {
|
||||
final payload = {
|
||||
'clientId': clientId,
|
||||
'messages': pending
|
||||
.map((m) => {
|
||||
'messageId': m.messageId,
|
||||
'body': m.body,
|
||||
'createdAt': m.createdAt.millisecondsSinceEpoch,
|
||||
})
|
||||
.toList(),
|
||||
};
|
||||
final res = await client.post(
|
||||
config.chatSendUri,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': config.apiKey,
|
||||
},
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
await _repository.markSynced(pending.map((e) => e.messageId).toList());
|
||||
debugPrint('[ChatSync] pushed ${pending.length} msgs');
|
||||
} else {
|
||||
debugPrint('[ChatSync] push failed ${res.statusCode} ${res.body}');
|
||||
}
|
||||
} catch (err) {
|
||||
debugPrint('[ChatSync] push error $err');
|
||||
} finally {
|
||||
if (_httpClient == null) client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchInbound() async {
|
||||
final config = await _baseClient.loadConfig();
|
||||
if (config == null) return;
|
||||
final clientId = await _baseClient.ensureClientId();
|
||||
final client = _httpClient ?? http.Client();
|
||||
try {
|
||||
final uri = config.chatPendingUri.replace(queryParameters: {'clientId': clientId});
|
||||
final res = await client.get(uri, headers: {'x-api-key': config.apiKey});
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final decoded = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
final messages = (decoded['messages'] as List?) ?? [];
|
||||
final ackIds = <String>[];
|
||||
for (final raw in messages.cast<Map>()) {
|
||||
final msg = ChatMessage(
|
||||
messageId: raw['messageId'] as String,
|
||||
clientId: clientId,
|
||||
direction: ChatDirection.inbound,
|
||||
body: raw['body'] as String,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch((raw['createdAt'] as int?) ?? 0, isUtc: true),
|
||||
synced: true,
|
||||
);
|
||||
await _repository.upsertInbound(msg);
|
||||
ackIds.add(msg.messageId);
|
||||
}
|
||||
if (ackIds.isNotEmpty) {
|
||||
await _ack(config, clientId, ackIds);
|
||||
}
|
||||
} else {
|
||||
debugPrint('[ChatSync] fetch failed ${res.statusCode} ${res.body}');
|
||||
}
|
||||
} catch (err) {
|
||||
debugPrint('[ChatSync] fetch error $err');
|
||||
} finally {
|
||||
if (_httpClient == null) client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ack(MothershipEndpointConfig config, String clientId, List<String> ids) async {
|
||||
final client = _httpClient ?? http.Client();
|
||||
try {
|
||||
await client.post(
|
||||
config.chatAckUri,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': config.apiKey,
|
||||
},
|
||||
body: jsonEncode({'clientId': clientId, 'delivered': ids}),
|
||||
);
|
||||
} catch (err) {
|
||||
debugPrint('[ChatSync] ack error $err');
|
||||
} finally {
|
||||
if (_httpClient == null) client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
153
lib/services/mothership_client.dart
Normal file
153
lib/services/mothership_client.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../utils/build_expiry_info.dart';
|
||||
import 'app_settings_repository.dart';
|
||||
|
||||
class MothershipClient {
|
||||
MothershipClient({AppSettingsRepository? settingsRepository, http.Client? httpClient})
|
||||
: _settingsRepository = settingsRepository ?? AppSettingsRepository(),
|
||||
_httpClient = httpClient;
|
||||
|
||||
final AppSettingsRepository _settingsRepository;
|
||||
final http.Client? _httpClient;
|
||||
|
||||
static const _clientIdKey = 'mothership_client_id';
|
||||
static const _hostSettingKey = 'external_host';
|
||||
static const _apiKeySettingKey = 'external_pass';
|
||||
|
||||
Future<void> sendHeartbeat(BuildExpiryInfo expiryInfo) async {
|
||||
final config = await loadConfig();
|
||||
if (config == null) {
|
||||
debugPrint('[Mothership] Heartbeat skipped: config not set');
|
||||
return;
|
||||
}
|
||||
final clientId = await ensureClientId();
|
||||
final remaining = expiryInfo.remaining?.inSeconds;
|
||||
await _postJson(
|
||||
uri: config.heartbeatUri,
|
||||
apiKey: config.apiKey,
|
||||
payload: {
|
||||
'clientId': clientId,
|
||||
if (remaining != null) 'remainingLifespanSeconds': remaining,
|
||||
},
|
||||
logLabel: 'heartbeat',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendHash(String hash) async {
|
||||
final config = await loadConfig();
|
||||
if (config == null) {
|
||||
debugPrint('[Mothership] Hash push skipped: config not set');
|
||||
return;
|
||||
}
|
||||
final clientId = await ensureClientId();
|
||||
await _postJson(
|
||||
uri: config.hashUri,
|
||||
apiKey: config.apiKey,
|
||||
payload: {
|
||||
'clientId': clientId,
|
||||
'hash': hash,
|
||||
},
|
||||
logLabel: 'hash',
|
||||
);
|
||||
}
|
||||
|
||||
Future<MothershipEndpointConfig?> loadConfig() async {
|
||||
final host = (await _settingsRepository.getString(_hostSettingKey))?.trim();
|
||||
final apiKey = (await _settingsRepository.getString(_apiKeySettingKey))?.trim();
|
||||
if (host == null || host.isEmpty || apiKey == null || apiKey.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final base = _normalizeBaseUri(host);
|
||||
return MothershipEndpointConfig(
|
||||
apiKey: apiKey,
|
||||
heartbeatUri: base.resolve('/sync/heartbeat'),
|
||||
hashUri: base.resolve('/sync/hash'),
|
||||
chatSendUri: base.resolve('/chat/send'),
|
||||
chatPendingUri: base.resolve('/chat/pending'),
|
||||
chatAckUri: base.resolve('/chat/ack'),
|
||||
);
|
||||
} on FormatException catch (err) {
|
||||
debugPrint('[Mothership] Invalid host "$host": $err');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uri _normalizeBaseUri(String host) {
|
||||
var normalized = host.trim();
|
||||
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
|
||||
normalized = 'http://$normalized';
|
||||
}
|
||||
if (!normalized.endsWith('/')) {
|
||||
normalized = '$normalized/';
|
||||
}
|
||||
return Uri.parse(normalized);
|
||||
}
|
||||
|
||||
Future<String> ensureClientId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final existing = prefs.getString(_clientIdKey);
|
||||
if (existing != null && existing.isNotEmpty) {
|
||||
return existing;
|
||||
}
|
||||
final newId = const Uuid().v4();
|
||||
await prefs.setString(_clientIdKey, newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
Future<void> _postJson({
|
||||
required Uri uri,
|
||||
required String apiKey,
|
||||
required Map<String, dynamic> payload,
|
||||
required String logLabel,
|
||||
}) async {
|
||||
final client = _httpClient ?? http.Client();
|
||||
try {
|
||||
final response = await client.post(
|
||||
uri,
|
||||
headers: {
|
||||
HttpHeaders.contentTypeHeader: 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
debugPrint('[Mothership] $logLabel OK (${response.statusCode})');
|
||||
} else {
|
||||
debugPrint('[Mothership] $logLabel failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
} catch (err, stack) {
|
||||
debugPrint('[Mothership] $logLabel error: $err');
|
||||
debugPrint('$stack');
|
||||
} finally {
|
||||
if (_httpClient == null) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MothershipEndpointConfig {
|
||||
MothershipEndpointConfig({
|
||||
required this.apiKey,
|
||||
required this.heartbeatUri,
|
||||
required this.hashUri,
|
||||
required this.chatSendUri,
|
||||
required this.chatPendingUri,
|
||||
required this.chatAckUri,
|
||||
});
|
||||
|
||||
final String apiKey;
|
||||
final Uri heartbeatUri;
|
||||
final Uri hashUri;
|
||||
final Uri chatSendUri;
|
||||
final Uri chatPendingUri;
|
||||
final Uri chatAckUri;
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ dependencies:
|
|||
shared_preferences: ^2.2.2
|
||||
mailer: ^6.0.1
|
||||
flutter_email_sender: ^6.0.3
|
||||
http: ^1.2.2
|
||||
shelf: ^1.4.1
|
||||
shelf_router: ^1.1.4
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue