お局様実装
This commit is contained in:
parent
a569f54b0b
commit
81fe44a4b0
8 changed files with 187 additions and 103 deletions
|
|
@ -29,7 +29,7 @@ class MothershipServer {
|
|||
final handler = const Pipeline()
|
||||
.addMiddleware(logRequests())
|
||||
.addMiddleware(_apiKeyMiddleware(config.apiKey))
|
||||
.addHandler(router);
|
||||
.addHandler(router.call);
|
||||
|
||||
final server = await serve(handler, config.host, config.port);
|
||||
return server;
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ class _MessageBubble extends StatelessWidget {
|
|||
borderRadius: borderRadius,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
|
|
@ -196,7 +196,7 @@ class _MessageBubble extends StatelessWidget {
|
|||
const SizedBox(height: 6),
|
||||
Text(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -219,11 +219,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
)
|
||||
: SlideToUnlock(
|
||||
isLocked: !_historyUnlocked,
|
||||
lockedText: 'スライドでロック解除 (A2)',
|
||||
unlockedText: 'A2解除済',
|
||||
onUnlocked: () async {
|
||||
setState(() => _historyUnlocked = true);
|
||||
await _repo.setDashboardHistoryUnlocked(true);
|
||||
},
|
||||
text: 'スライドでロック解除 (A2)',
|
||||
),
|
||||
),
|
||||
if (_statusEnabled)
|
||||
|
|
|
|||
|
|
@ -219,61 +219,72 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
drawer: (_useDashboardHome || !_isUnlocked)
|
||||
? null
|
||||
: Drawer(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
DrawerHeader(
|
||||
decoration: const BoxDecoration(color: Colors.indigo),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)),
|
||||
SizedBox(height: 8),
|
||||
Text("メニュー", style: TextStyle(color: Colors.white70)),
|
||||
],
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
DrawerHeader(
|
||||
decoration: const BoxDecoration(color: Colors.indigo),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)),
|
||||
SizedBox(height: 8),
|
||||
Text("クイックメニュー", style: TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.receipt_long),
|
||||
title: const Text("伝票マスター"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.people),
|
||||
title: const Text("顧客マスター"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen()));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.inventory_2),
|
||||
title: const Text("商品マスター"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen()));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: const Text("設定"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.admin_panel_settings),
|
||||
title: const Text("管理メニュー"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const ManagementScreen()));
|
||||
},
|
||||
),
|
||||
],
|
||||
_drawerHeading("アクション"),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_circle_outline, color: Colors.indigo),
|
||||
title: const Text("新しい伝票を作成"),
|
||||
subtitle: const Text("ドキュメント種別を選択"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showCreateTypeMenu();
|
||||
},
|
||||
),
|
||||
_drawerHeading("マスター"),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.receipt_long),
|
||||
title: const Text("伝票マスター"),
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.people),
|
||||
title: const Text("顧客マスター"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen()));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.inventory_2),
|
||||
title: const Text("商品マスター"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen()));
|
||||
},
|
||||
),
|
||||
_drawerHeading("システム"),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: const Text("設定"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
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(
|
||||
|
|
@ -386,11 +397,12 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
children: [
|
||||
if (!_useDashboardHome)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: SlideToUnlock(
|
||||
isLocked: !_isUnlocked,
|
||||
lockedText: "A2をロック解除",
|
||||
unlockedText: "解除済",
|
||||
onUnlocked: _toggleUnlock,
|
||||
text: "スライドでロック解除",
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
|
@ -436,7 +448,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
onPressed: _isUnlocked
|
||||
? () => _showCreateTypeMenu()
|
||||
: _requireUnlock,
|
||||
label: const Text("新規伝票作成"),
|
||||
label: const Text("新しい伝票"),
|
||||
icon: const Icon(Icons.add),
|
||||
backgroundColor: Colors.indigo,
|
||||
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() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
|
|
|||
|
|
@ -29,13 +29,14 @@ class MothershipClient {
|
|||
}
|
||||
final clientId = await ensureClientId();
|
||||
final remaining = expiryInfo.remaining?.inSeconds;
|
||||
final payload = <String, dynamic>{'clientId': clientId};
|
||||
if (remaining != null) {
|
||||
payload['remainingLifespanSeconds'] = remaining;
|
||||
}
|
||||
await _postJson(
|
||||
uri: config.heartbeatUri,
|
||||
apiKey: config.apiKey,
|
||||
payload: {
|
||||
'clientId': clientId,
|
||||
if (remaining != null) 'remainingLifespanSeconds': remaining,
|
||||
},
|
||||
payload: payload,
|
||||
logLabel: 'heartbeat',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,28 @@ import 'package:flutter/material.dart';
|
|||
|
||||
class SlideToUnlock extends StatefulWidget {
|
||||
final VoidCallback onUnlocked;
|
||||
final String text;
|
||||
final String lockedText;
|
||||
final String unlockedText;
|
||||
final IconData lockedIcon;
|
||||
final IconData unlockedIcon;
|
||||
final bool isLocked;
|
||||
final double? height;
|
||||
final double? thumbSize;
|
||||
final Color? backgroundColor;
|
||||
final Color? accentColor;
|
||||
|
||||
const SlideToUnlock({
|
||||
super.key,
|
||||
required this.onUnlocked,
|
||||
this.text = "スライドして解除",
|
||||
this.lockedText = "スライドして解除",
|
||||
this.unlockedText = "UNLOCKED",
|
||||
this.lockedIcon = Icons.lock,
|
||||
this.unlockedIcon = Icons.check_circle,
|
||||
this.isLocked = true,
|
||||
this.height = 72,
|
||||
this.thumbSize = 52,
|
||||
this.backgroundColor,
|
||||
this.accentColor,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -18,7 +32,8 @@ class SlideToUnlock extends StatefulWidget {
|
|||
|
||||
class _SlideToUnlockState extends State<SlideToUnlock> {
|
||||
double _position = 0.0;
|
||||
final double _thumbSize = 56.0;
|
||||
static const double _trackPadding = 14.0;
|
||||
bool _showSuccessOverlay = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -27,13 +42,20 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
|||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
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(
|
||||
height: 64,
|
||||
height: widget.height ?? 72,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueGrey.shade900,
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 8, offset: const Offset(0, 4)),
|
||||
|
|
@ -41,27 +63,63 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
|||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 背景テキストとアニメーション効果(簡易)
|
||||
Center(
|
||||
child: Opacity(
|
||||
opacity: (1 - (_position / trackWidth)).clamp(0.2, 1.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.keyboard_double_arrow_right, color: Colors.white54, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.text,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 1.2),
|
||||
// 進行バー
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _trackPadding, vertical: 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
width: progressWidth,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
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(
|
||||
left: _position + 4,
|
||||
top: 4,
|
||||
left: _trackPadding + _position,
|
||||
top: ((widget.height ?? 72) - (widget.thumbSize ?? 52)) / 2,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragUpdate: (details) {
|
||||
setState(() {
|
||||
|
|
@ -72,31 +130,34 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
|||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (_position >= trackWidth * 0.65) {
|
||||
setState(() {
|
||||
_position = trackWidth;
|
||||
_showSuccessOverlay = true;
|
||||
});
|
||||
widget.onUnlocked();
|
||||
// 成功時はアニメーションで戻すのではなく、状態が変わるのでリセット
|
||||
setState(() => _position = 0);
|
||||
Future.delayed(const Duration(milliseconds: 450), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_position = 0;
|
||||
_showSuccessOverlay = false;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 失敗時はバネのように戻る(簡易)
|
||||
setState(() => _position = 0);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: _thumbSize,
|
||||
height: 56,
|
||||
width: thumbSize,
|
||||
height: widget.thumbSize ?? 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Colors.orangeAccent, Colors.deepOrange],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black45, blurRadius: 4, offset: const Offset(2, 2)),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular((widget.thumbSize ?? 52) / 2),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black38, blurRadius: 8, offset: Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(Icons.key, color: Colors.white, size: 24),
|
||||
),
|
||||
child: Icon(Icons.arrow_forward_ios, color: accentEnd, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.0.1"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:h_1/main.dart';
|
||||
import 'package:h_1/utils/build_expiry_info.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// 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.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
|
|
|
|||
Loading…
Reference in a new issue