679 lines
18 KiB
Dart
679 lines
18 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../../../core/calls/call_session.dart';
|
|
import '../../../core/games/checkers.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
import '../../widgets/sheet_helpers.dart';
|
|
import '../../../core/config/app_fonts.dart';
|
|
|
|
Future<void> showKometHub(
|
|
BuildContext context, {
|
|
required CallSession session,
|
|
required ColorScheme scheme,
|
|
}) {
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
backgroundColor: scheme.surfaceContainerHigh,
|
|
shape: kSheetShape,
|
|
builder: (_) => Theme(
|
|
data: Theme.of(context).copyWith(colorScheme: scheme),
|
|
child: _KometHub(session: session),
|
|
),
|
|
);
|
|
}
|
|
|
|
enum _HubPage { menu, chat, games, checkers }
|
|
|
|
class _KometHub extends StatefulWidget {
|
|
final CallSession session;
|
|
|
|
const _KometHub({required this.session});
|
|
|
|
@override
|
|
State<_KometHub> createState() => _KometHubState();
|
|
}
|
|
|
|
class _KometHubState extends State<_KometHub> {
|
|
_HubPage _page = _HubPage.menu;
|
|
|
|
void _go(_HubPage page) => setState(() => _page = page);
|
|
|
|
void _back() {
|
|
switch (_page) {
|
|
case _HubPage.menu:
|
|
Navigator.of(context).maybePop();
|
|
break;
|
|
case _HubPage.checkers:
|
|
_go(_HubPage.games);
|
|
break;
|
|
case _HubPage.chat:
|
|
case _HubPage.games:
|
|
_go(_HubPage.menu);
|
|
break;
|
|
}
|
|
}
|
|
|
|
String get _title {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
switch (_page) {
|
|
case _HubPage.menu:
|
|
return l10n.hubTitleMenu;
|
|
case _HubPage.chat:
|
|
return l10n.hubChatPageTitle;
|
|
case _HubPage.games:
|
|
return l10n.hubGamesTitle;
|
|
case _HubPage.checkers:
|
|
return l10n.hubCheckersTitle;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.78,
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_header(cs),
|
|
Flexible(child: _body(cs)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _header(ColorScheme cs) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 0, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: _back,
|
|
icon: Icon(
|
|
_page == _HubPage.menu ? Symbols.close : Symbols.arrow_back,
|
|
color: cs.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
_title,
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
fontFamily: displayFontOf(context),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _body(ColorScheme cs) {
|
|
switch (_page) {
|
|
case _HubPage.menu:
|
|
return _menu(cs);
|
|
case _HubPage.games:
|
|
return _games(cs);
|
|
case _HubPage.chat:
|
|
return _KometChatView(session: widget.session);
|
|
case _HubPage.checkers:
|
|
return _CheckersView(session: widget.session);
|
|
}
|
|
}
|
|
|
|
Widget _menu(ColorScheme cs) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_tile(
|
|
cs,
|
|
Symbols.forum,
|
|
l10n.hubChatTileTitle,
|
|
l10n.hubChatTileSubtitle,
|
|
() => _go(_HubPage.chat),
|
|
),
|
|
_tile(
|
|
cs,
|
|
Symbols.stadia_controller,
|
|
l10n.hubGamesTitle,
|
|
l10n.hubGamesTileSubtitle,
|
|
() => _go(_HubPage.games),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _games(ColorScheme cs) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_tile(
|
|
cs,
|
|
Symbols.grid_on,
|
|
l10n.hubCheckersTitle,
|
|
l10n.hubCheckersTileSubtitle,
|
|
() => _go(_HubPage.checkers),
|
|
),
|
|
_tile(
|
|
cs,
|
|
Symbols.more_horiz,
|
|
l10n.hubMoreSoonTitle,
|
|
l10n.hubMoreSoonSubtitle,
|
|
null,
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _tile(
|
|
ColorScheme cs,
|
|
IconData icon,
|
|
String title,
|
|
String subtitle,
|
|
VoidCallback? onTap,
|
|
) {
|
|
final enabled = onTap != null;
|
|
return ListTile(
|
|
onTap: onTap,
|
|
enabled: enabled,
|
|
leading: Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(icon, color: enabled ? cs.primary : cs.onSurfaceVariant),
|
|
),
|
|
title: Text(
|
|
title,
|
|
style: TextStyle(
|
|
color: enabled ? cs.onSurface : cs.onSurfaceVariant,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
subtitle,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
),
|
|
trailing: enabled
|
|
? Icon(Symbols.chevron_right, color: cs.onSurfaceVariant)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _KometChatView extends StatefulWidget {
|
|
final CallSession session;
|
|
|
|
const _KometChatView({required this.session});
|
|
|
|
@override
|
|
State<_KometChatView> createState() => _KometChatViewState();
|
|
}
|
|
|
|
class _KometChatViewState extends State<_KometChatView> {
|
|
final TextEditingController _controller = TextEditingController();
|
|
final ScrollController _scroll = ScrollController();
|
|
StreamSubscription<CallChatMessage>? _sub;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_sub = widget.session.chatMessages.listen((_) {
|
|
if (mounted) setState(() {});
|
|
_scrollToBottom();
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!_scroll.hasClients) return;
|
|
_scroll.animateTo(
|
|
_scroll.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOut,
|
|
);
|
|
});
|
|
}
|
|
|
|
void _send() {
|
|
final text = _controller.text.trim();
|
|
if (text.isEmpty) return;
|
|
widget.session.sendChatMessage(text);
|
|
_controller.clear();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sub?.cancel();
|
|
_controller.dispose();
|
|
_scroll.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final cs = Theme.of(context).colorScheme;
|
|
final messages = widget.session.chatLog;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 6),
|
|
child: Row(
|
|
children: [
|
|
Icon(Symbols.lock, size: 16, color: cs.primary, fill: 1),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.hubChatPrivacyNote,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Flexible(
|
|
child: messages.isEmpty
|
|
? _empty(cs)
|
|
: ListView.builder(
|
|
controller: _scroll,
|
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
|
|
itemCount: messages.length,
|
|
itemBuilder: (_, i) => _bubble(cs, messages[i]),
|
|
),
|
|
),
|
|
_input(cs),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _empty(ColorScheme cs) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Text(
|
|
AppLocalizations.of(context)!.hubChatEmpty,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _bubble(ColorScheme cs, CallChatMessage message) {
|
|
return Align(
|
|
alignment: message.mine ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.72,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: message.mine ? cs.primary : cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Text(
|
|
message.text,
|
|
style: TextStyle(
|
|
color: message.mine ? cs.onPrimary : cs.onSurface,
|
|
fontSize: 15,
|
|
height: 1.25,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _input(ColorScheme cs) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
minLines: 1,
|
|
maxLines: 4,
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: (_) => _send(),
|
|
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
|
decoration: InputDecoration(
|
|
hintText: AppLocalizations.of(context)!.hubChatInputHint,
|
|
hintStyle: TextStyle(color: cs.onSurfaceVariant),
|
|
filled: true,
|
|
fillColor: cs.surfaceContainerHighest,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 11,
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.filled(
|
|
onPressed: _send,
|
|
icon: const Icon(Symbols.send, fill: 1),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CheckersView extends StatefulWidget {
|
|
final CallSession session;
|
|
|
|
const _CheckersView({required this.session});
|
|
|
|
@override
|
|
State<_CheckersView> createState() => _CheckersViewState();
|
|
}
|
|
|
|
class _CheckersViewState extends State<_CheckersView> {
|
|
List<int> _board = Checkers.initial();
|
|
CheckersSide _turn = CheckersSide.white;
|
|
List<int> _path = const [];
|
|
List<List<int>> _legal = const [];
|
|
CheckersSide? _result;
|
|
late final CheckersSide _me;
|
|
StreamSubscription<Map<String, dynamic>>? _sub;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final self = widget.session.selfUserId;
|
|
final peer = widget.session.peerUserId ?? (self + 1);
|
|
_me = self <= peer ? CheckersSide.white : CheckersSide.black;
|
|
_recompute();
|
|
_sub = widget.session.gameMessages.listen(_onGame);
|
|
}
|
|
|
|
void _recompute() {
|
|
_legal = Checkers.legalMoves(_board, _turn);
|
|
_result = _legal.isEmpty ? Checkers.opponent(_turn) : null;
|
|
}
|
|
|
|
void _restart() {
|
|
_board = Checkers.initial();
|
|
_turn = CheckersSide.white;
|
|
_path = const [];
|
|
_recompute();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onGame(Map<String, dynamic> data) {
|
|
if (data['g'] != 'checkers') return;
|
|
if (data['a'] == 'reset') {
|
|
setState(_restart);
|
|
return;
|
|
}
|
|
if (data['a'] == 'move') {
|
|
final raw = data['path'];
|
|
if (raw is! List) return;
|
|
_applyMove(raw.map((e) => e as int).toList(), fromRemote: true);
|
|
}
|
|
}
|
|
|
|
void _applyMove(List<int> path, {required bool fromRemote}) {
|
|
if (!_legal.any((p) => _listEq(p, path))) return;
|
|
setState(() {
|
|
_board = Checkers.applyMove(_board, path);
|
|
_turn = Checkers.opponent(_turn);
|
|
_path = const [];
|
|
_recompute();
|
|
});
|
|
if (!fromRemote) {
|
|
widget.session.sendGame({'g': 'checkers', 'a': 'move', 'path': path});
|
|
}
|
|
}
|
|
|
|
void _reset() {
|
|
setState(_restart);
|
|
widget.session.sendGame({'g': 'checkers', 'a': 'reset'});
|
|
}
|
|
|
|
void _onTap(int square) {
|
|
if (_turn != _me || _result != null) return;
|
|
|
|
if (_path.isEmpty) {
|
|
if (_legal.any((p) => p.first == square)) {
|
|
setState(() => _path = [square]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final prefix = [..._path, square];
|
|
final matching = _legal.where((p) => _startsWith(p, prefix)).toList();
|
|
if (matching.isEmpty) {
|
|
setState(
|
|
() =>
|
|
_path = _legal.any((p) => p.first == square) ? [square] : const [],
|
|
);
|
|
return;
|
|
}
|
|
if (matching.any((p) => p.length == prefix.length)) {
|
|
_applyMove(prefix, fromRemote: false);
|
|
} else {
|
|
setState(() => _path = prefix);
|
|
}
|
|
}
|
|
|
|
Set<int> _options() {
|
|
if (_turn != _me || _result != null) return const {};
|
|
if (_path.isEmpty) return {for (final p in _legal) p.first};
|
|
final next = <int>{};
|
|
for (final p in _legal) {
|
|
if (_startsWith(p, _path) && p.length > _path.length) {
|
|
next.add(p[_path.length]);
|
|
}
|
|
}
|
|
return next;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final cs = Theme.of(context).colorScheme;
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_status(),
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: _reset,
|
|
icon: const Icon(Symbols.refresh, size: 20),
|
|
label: Text(l10n.hubCheckersRestart),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
_boardWidget(cs),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
_me == CheckersSide.white
|
|
? l10n.hubCheckersYouWhite
|
|
: l10n.hubCheckersYouBlack,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _status() {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final w = _result;
|
|
if (w != null) return w == _me ? l10n.hubCheckersWon : l10n.hubCheckersLost;
|
|
return _turn == _me
|
|
? l10n.hubCheckersYourMove
|
|
: l10n.hubCheckersOpponentMove;
|
|
}
|
|
|
|
Widget _boardWidget(ColorScheme cs) {
|
|
final flip = _me == CheckersSide.black;
|
|
final options = _options();
|
|
final selected = _path.isNotEmpty ? _path.last : -1;
|
|
|
|
return AspectRatio(
|
|
aspectRatio: 1,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: GridView.builder(
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: Checkers.size,
|
|
),
|
|
itemCount: Checkers.size * Checkers.size,
|
|
itemBuilder: (_, i) {
|
|
final square = flip ? (Checkers.size * Checkers.size - 1 - i) : i;
|
|
final rb = square ~/ Checkers.size;
|
|
final cb = square % Checkers.size;
|
|
final dark = (rb + cb) % 2 == 1;
|
|
return GestureDetector(
|
|
onTap: dark ? () => _onTap(square) : null,
|
|
child: _cell(
|
|
cs,
|
|
dark: dark,
|
|
piece: _board[square],
|
|
option: options.contains(square),
|
|
selected: square == selected,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _cell(
|
|
ColorScheme cs, {
|
|
required bool dark,
|
|
required int piece,
|
|
required bool option,
|
|
required bool selected,
|
|
}) {
|
|
final base = dark ? cs.surfaceContainerHighest : cs.surfaceContainerLow;
|
|
return Container(
|
|
color: selected ? cs.primary.withValues(alpha: 0.45) : base,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
if (option && piece == Checkers.empty)
|
|
FractionallySizedBox(
|
|
widthFactor: 0.34,
|
|
heightFactor: 0.34,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: cs.primary.withValues(alpha: 0.55),
|
|
),
|
|
),
|
|
),
|
|
if (piece != Checkers.empty) _piece(cs, piece, option),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _piece(ColorScheme cs, int piece, bool option) {
|
|
final white = Checkers.sideOf(piece) == CheckersSide.white;
|
|
final king = Checkers.isKing(piece);
|
|
return FractionallySizedBox(
|
|
widthFactor: 0.76,
|
|
heightFactor: 0.76,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: white ? const Color(0xFFEDEDED) : const Color(0xFF262626),
|
|
border: Border.all(
|
|
color: option
|
|
? cs.primary
|
|
: (white ? const Color(0xFFB8B8B8) : const Color(0xFF050505)),
|
|
width: option ? 2.5 : 1.5,
|
|
),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Colors.black38,
|
|
blurRadius: 3,
|
|
offset: Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: king
|
|
? Icon(
|
|
Symbols.star,
|
|
fill: 1,
|
|
size: 16,
|
|
color: white
|
|
? const Color(0xFF8A6D00)
|
|
: const Color(0xFFE7C200),
|
|
)
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _startsWith(List<int> path, List<int> prefix) {
|
|
if (path.length < prefix.length) return false;
|
|
for (var i = 0; i < prefix.length; i++) {
|
|
if (path[i] != prefix[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool _listEq(List<int> a, List<int> b) {
|
|
if (a.length != b.length) return false;
|
|
for (var i = 0; i < a.length; i++) {
|
|
if (a[i] != b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|