お局様実装

This commit is contained in:
joe 2026-03-02 11:06:47 +09:00
parent a569f54b0b
commit 81fe44a4b0
8 changed files with 187 additions and 103 deletions

View file

@ -29,7 +29,7 @@ class MothershipServer {
final handler = const Pipeline() final handler = const Pipeline()
.addMiddleware(logRequests()) .addMiddleware(logRequests())
.addMiddleware(_apiKeyMiddleware(config.apiKey)) .addMiddleware(_apiKeyMiddleware(config.apiKey))
.addHandler(router); .addHandler(router.call);
final server = await serve(handler, config.host, config.port); final server = await serve(handler, config.host, config.port);
return server; return server;

View file

@ -180,7 +180,7 @@ class _MessageBubble extends StatelessWidget {
borderRadius: borderRadius, borderRadius: borderRadius,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.05), color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
@ -196,7 +196,7 @@ class _MessageBubble extends StatelessWidget {
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
timeText, timeText,
style: theme.textTheme.labelSmall?.copyWith(color: textColor.withOpacity(0.8), fontSize: 11), style: theme.textTheme.labelSmall?.copyWith(color: textColor.withValues(alpha: 0.8), fontSize: 11),
), ),
], ],
), ),

View file

@ -219,11 +219,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
) )
: SlideToUnlock( : SlideToUnlock(
isLocked: !_historyUnlocked, isLocked: !_historyUnlocked,
lockedText: 'スライドでロック解除 (A2)',
unlockedText: 'A2解除済',
onUnlocked: () async { onUnlocked: () async {
setState(() => _historyUnlocked = true); setState(() => _historyUnlocked = true);
await _repo.setDashboardHistoryUnlocked(true); await _repo.setDashboardHistoryUnlocked(true);
}, },
text: 'スライドでロック解除 (A2)',
), ),
), ),
if (_statusEnabled) if (_statusEnabled)

View file

@ -219,61 +219,72 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
drawer: (_useDashboardHome || !_isUnlocked) drawer: (_useDashboardHome || !_isUnlocked)
? null ? null
: Drawer( : Drawer(
child: ListView( child: SafeArea(
padding: EdgeInsets.zero, child: ListView(
children: [ padding: EdgeInsets.zero,
DrawerHeader( children: [
decoration: const BoxDecoration(color: Colors.indigo), DrawerHeader(
child: Column( decoration: const BoxDecoration(color: Colors.indigo),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: const [ crossAxisAlignment: CrossAxisAlignment.start,
Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)), children: const [
SizedBox(height: 8), Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)),
Text("メニュー", style: TextStyle(color: Colors.white70)), SizedBox(height: 8),
], Text("クイックメニュー", style: TextStyle(color: Colors.white70)),
],
),
), ),
), _drawerHeading("アクション"),
ListTile( ListTile(
leading: const Icon(Icons.receipt_long), leading: const Icon(Icons.add_circle_outline, color: Colors.indigo),
title: const Text("伝票マスター"), title: const Text("新しい伝票を作成"),
onTap: () { subtitle: const Text("ドキュメント種別を選択"),
Navigator.pop(context); onTap: () {
}, Navigator.pop(context);
), _showCreateTypeMenu();
ListTile( },
leading: const Icon(Icons.people), ),
title: const Text("顧客マスター"), _drawerHeading("マスター"),
onTap: () { ListTile(
Navigator.pop(context); leading: const Icon(Icons.receipt_long),
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen())); title: const Text("伝票マスター"),
}, onTap: () => Navigator.pop(context),
), ),
ListTile( ListTile(
leading: const Icon(Icons.inventory_2), leading: const Icon(Icons.people),
title: const Text("商品マスター"), title: const Text("顧客マスター"),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen())); Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen()));
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.settings), leading: const Icon(Icons.inventory_2),
title: const Text("設定"), title: const Text("商品マスター"),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())); Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen()));
}, },
), ),
const Divider(), _drawerHeading("システム"),
ListTile( ListTile(
leading: const Icon(Icons.admin_panel_settings), leading: const Icon(Icons.settings),
title: const Text("管理メニュー"), title: const Text("設定"),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const ManagementScreen())); Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen()));
}, },
), ),
], ListTile(
leading: const Icon(Icons.admin_panel_settings),
title: const Text("管理メニュー"),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const ManagementScreen()));
},
),
],
),
), ),
), ),
appBar: AppBar( appBar: AppBar(
@ -386,11 +397,12 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
children: [ children: [
if (!_useDashboardHome) if (!_useDashboardHome)
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: SlideToUnlock( child: SlideToUnlock(
isLocked: !_isUnlocked, isLocked: !_isUnlocked,
lockedText: "A2をロック解除",
unlockedText: "解除済",
onUnlocked: _toggleUnlock, onUnlocked: _toggleUnlock,
text: "スライドでロック解除",
), ),
), ),
Expanded( Expanded(
@ -436,7 +448,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
onPressed: _isUnlocked onPressed: _isUnlocked
? () => _showCreateTypeMenu() ? () => _showCreateTypeMenu()
: _requireUnlock, : _requireUnlock,
label: const Text("規伝票作成"), label: const Text("しい伝票"),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
backgroundColor: Colors.indigo, backgroundColor: Colors.indigo,
foregroundColor: Colors.white, foregroundColor: Colors.white,
@ -444,6 +456,13 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
); );
} }
Widget _drawerHeading(String label) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey, letterSpacing: 0.5)),
);
}
void _showCreateTypeMenu() { void _showCreateTypeMenu() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,

View file

@ -29,13 +29,14 @@ class MothershipClient {
} }
final clientId = await ensureClientId(); final clientId = await ensureClientId();
final remaining = expiryInfo.remaining?.inSeconds; final remaining = expiryInfo.remaining?.inSeconds;
final payload = <String, dynamic>{'clientId': clientId};
if (remaining != null) {
payload['remainingLifespanSeconds'] = remaining;
}
await _postJson( await _postJson(
uri: config.heartbeatUri, uri: config.heartbeatUri,
apiKey: config.apiKey, apiKey: config.apiKey,
payload: { payload: payload,
'clientId': clientId,
if (remaining != null) 'remainingLifespanSeconds': remaining,
},
logLabel: 'heartbeat', logLabel: 'heartbeat',
); );
} }

View file

@ -2,14 +2,28 @@ import 'package:flutter/material.dart';
class SlideToUnlock extends StatefulWidget { class SlideToUnlock extends StatefulWidget {
final VoidCallback onUnlocked; final VoidCallback onUnlocked;
final String text; final String lockedText;
final String unlockedText;
final IconData lockedIcon;
final IconData unlockedIcon;
final bool isLocked; final bool isLocked;
final double? height;
final double? thumbSize;
final Color? backgroundColor;
final Color? accentColor;
const SlideToUnlock({ const SlideToUnlock({
super.key, super.key,
required this.onUnlocked, required this.onUnlocked,
this.text = "スライドして解除", this.lockedText = "スライドして解除",
this.unlockedText = "UNLOCKED",
this.lockedIcon = Icons.lock,
this.unlockedIcon = Icons.check_circle,
this.isLocked = true, this.isLocked = true,
this.height = 72,
this.thumbSize = 52,
this.backgroundColor,
this.accentColor,
}); });
@override @override
@ -18,7 +32,8 @@ class SlideToUnlock extends StatefulWidget {
class _SlideToUnlockState extends State<SlideToUnlock> { class _SlideToUnlockState extends State<SlideToUnlock> {
double _position = 0.0; double _position = 0.0;
final double _thumbSize = 56.0; static const double _trackPadding = 14.0;
bool _showSuccessOverlay = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -27,13 +42,20 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final double maxWidth = constraints.maxWidth; final double maxWidth = constraints.maxWidth;
final double trackWidth = (maxWidth - _thumbSize - 12).clamp(0, maxWidth); final double thumbSize = widget.thumbSize ?? 52;
final double trackWidth = (maxWidth - thumbSize - (_trackPadding * 2)).clamp(0, maxWidth);
final double progressRatio = trackWidth == 0 ? 0 : (_position / trackWidth).clamp(0, 1);
final double innerWidth = thumbSize + trackWidth;
final double progressWidth = (innerWidth * progressRatio + thumbSize * (1 - progressRatio)).clamp(thumbSize, innerWidth);
final Color background = widget.backgroundColor ?? Colors.blueGrey.shade900;
final Color accentStart = (widget.accentColor ?? Colors.indigo.shade600).withValues(alpha: 0.9);
final Color accentEnd = (widget.accentColor ?? Colors.indigo.shade600);
return Container( return Container(
height: 64, height: widget.height ?? 72,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade900, color: background,
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
boxShadow: [ boxShadow: [
BoxShadow(color: Colors.black26, blurRadius: 8, offset: const Offset(0, 4)), BoxShadow(color: Colors.black26, blurRadius: 8, offset: const Offset(0, 4)),
@ -41,27 +63,63 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
), ),
child: Stack( child: Stack(
children: [ children: [
// //
Center( Padding(
child: Opacity( padding: const EdgeInsets.symmetric(horizontal: _trackPadding, vertical: 12),
opacity: (1 - (_position / trackWidth)).clamp(0.2, 1.0), child: Align(
child: Row( alignment: Alignment.centerLeft,
mainAxisAlignment: MainAxisAlignment.center, child: AnimatedContainer(
children: [ duration: const Duration(milliseconds: 150),
const Icon(Icons.keyboard_double_arrow_right, color: Colors.white54, size: 20), curve: Curves.easeOut,
const SizedBox(width: 8), width: progressWidth,
Text( height: double.infinity,
widget.text, decoration: BoxDecoration(
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 1.2), borderRadius: BorderRadius.circular(28),
gradient: LinearGradient(
colors: [
accentStart,
accentEnd,
],
), ),
], boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 2)),
],
),
), ),
), ),
), ),
//
Center(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _showSuccessOverlay
? Row(
mainAxisSize: MainAxisSize.min,
key: const ValueKey('unlocked'),
children: [
Icon(widget.unlockedIcon, color: Colors.white, size: 24),
const SizedBox(width: 6),
Text(widget.unlockedText, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
],
)
: Row(
key: const ValueKey('locked'),
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(widget.lockedIcon, color: Colors.white.withValues(alpha: 0.85), size: 20),
const SizedBox(width: 8),
Text(
widget.lockedText,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
],
),
),
),
// //
Positioned( Positioned(
left: _position + 4, left: _trackPadding + _position,
top: 4, top: ((widget.height ?? 72) - (widget.thumbSize ?? 52)) / 2,
child: GestureDetector( child: GestureDetector(
onHorizontalDragUpdate: (details) { onHorizontalDragUpdate: (details) {
setState(() { setState(() {
@ -72,31 +130,34 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
}, },
onHorizontalDragEnd: (details) { onHorizontalDragEnd: (details) {
if (_position >= trackWidth * 0.65) { if (_position >= trackWidth * 0.65) {
setState(() {
_position = trackWidth;
_showSuccessOverlay = true;
});
widget.onUnlocked(); widget.onUnlocked();
// Future.delayed(const Duration(milliseconds: 450), () {
setState(() => _position = 0); if (!mounted) return;
setState(() {
_position = 0;
_showSuccessOverlay = false;
});
});
} else { } else {
// //
setState(() => _position = 0); setState(() => _position = 0);
} }
}, },
child: Container( child: Container(
width: _thumbSize, width: thumbSize,
height: 56, height: widget.thumbSize ?? 52,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( color: Colors.white,
colors: [Colors.orangeAccent, Colors.deepOrange], borderRadius: BorderRadius.circular((widget.thumbSize ?? 52) / 2),
begin: Alignment.topLeft, boxShadow: const [
end: Alignment.bottomRight, BoxShadow(color: Colors.black38, blurRadius: 8, offset: Offset(0, 4)),
),
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(color: Colors.black45, blurRadius: 4, offset: const Offset(2, 2)),
], ],
), ),
child: const Center( child: Icon(Icons.arrow_forward_ios, color: accentEnd, size: 20),
child: Icon(Icons.key, color: Colors.white, size: 24),
),
), ),
), ),
), ),

View file

@ -313,7 +313,7 @@ packages:
source: hosted source: hosted
version: "1.0.1" version: "1.0.1"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"

View file

@ -9,11 +9,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:h_1/main.dart'; import 'package:h_1/main.dart';
import 'package:h_1/utils/build_expiry_info.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget(const MyApp()); final expiryInfo = BuildExpiryInfo.fromEnvironment();
await tester.pumpWidget(MyApp(expiryInfo: expiryInfo));
// Verify that our counter starts at 0. // Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget); expect(find.text('0'), findsOneWidget);