feat(frontend): add multi-language support (en, zh-Hans, zh-Hant, ja)

This commit is contained in:
2026-05-25 11:49:53 +08:00
parent 1539e495e6
commit 261b1ab169
20 changed files with 1955 additions and 139 deletions

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
@@ -22,6 +23,7 @@ class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
bool isWide = MediaQuery.of(context).size.width > 600;
final l10n = AppLocalizations.of(context)!;
final List<Widget> pages = [
_ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)),
@@ -38,18 +40,18 @@ class _HomePageState extends State<HomePage> {
onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
labelType: NavigationRailLabelType.all,
destinations: const [
destinations: [
NavigationRailDestination(
icon: Icon(Icons.explore),
label: Text('Explore'),
icon: const Icon(Icons.explore),
label: Text(l10n.explore),
),
NavigationRailDestination(
icon: Icon(Icons.videocam),
label: Text('Console'),
icon: const Icon(Icons.videocam),
label: Text(l10n.console),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Settings'),
icon: const Icon(Icons.settings),
label: Text(l10n.settings),
),
],
),
@@ -61,18 +63,18 @@ class _HomePageState extends State<HomePage> {
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
destinations: const [
destinations: [
NavigationDestination(
icon: Icon(Icons.explore),
label: 'Explore',
icon: const Icon(Icons.explore),
label: l10n.explore,
),
NavigationDestination(
icon: Icon(Icons.videocam),
label: 'Console',
icon: const Icon(Icons.videocam),
label: l10n.console,
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Settings',
icon: const Icon(Icons.settings),
label: l10n.settings,
),
],
)
@@ -131,9 +133,10 @@ class _ExploreViewState extends State<_ExploreView> {
}
} catch (e) {
if (!isAuto && mounted) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
).showSnackBar(SnackBar(content: Text(l10n?.failedToLoadRooms ?? "Failed to load rooms")));
}
} finally {
if (!isAuto && mounted) setState(() => _isLoading = false);
@@ -141,20 +144,21 @@ class _ExploreViewState extends State<_ExploreView> {
}
Future<void> _confirmLogout() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Confirm Logout'),
content: const Text('Are you sure you want to log out now?'),
title: Text(l10n.confirmLogout),
content: Text(l10n.confirmLogoutDesc),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Logout'),
child: Text(l10n.logout),
),
],
);
@@ -169,44 +173,45 @@ class _ExploreViewState extends State<_ExploreView> {
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>();
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)),
title: Text(l10n.explore, style: const TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(
icon: Icon(Icons.refresh),
icon: const Icon(Icons.refresh),
onPressed: () => _refreshRooms(),
),
IconButton(icon: Icon(Icons.logout), onPressed: _confirmLogout),
IconButton(icon: const Icon(Icons.logout), onPressed: _confirmLogout),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: widget.onGoLive,
label: Text("Go Live"),
icon: Icon(Icons.videocam),
label: Text(l10n.goLive),
icon: const Icon(Icons.videocam),
),
body: RefreshIndicator(
onRefresh: _refreshRooms,
child: _isLoading && _activeRooms.isEmpty
? Center(child: CircularProgressIndicator())
? const Center(child: CircularProgressIndicator())
: _activeRooms.isEmpty
? ListView(
children: [
Padding(
padding: EdgeInsets.only(top: 100),
padding: const EdgeInsets.only(top: 100),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
const Icon(
Icons.live_tv_outlined,
size: 80,
color: Colors.grey,
),
SizedBox(height: 16),
const SizedBox(height: 16),
Text(
"No active rooms. Be the first!",
style: TextStyle(color: Colors.grey, fontSize: 16),
l10n.noActiveRooms,
style: const TextStyle(color: Colors.grey, fontSize: 16),
),
],
),
@@ -214,8 +219,8 @@ class _ExploreViewState extends State<_ExploreView> {
],
)
: GridView.builder(
padding: EdgeInsets.all(12),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400,
childAspectRatio: 1.2,
crossAxisSpacing: 12,
@@ -224,14 +229,14 @@ class _ExploreViewState extends State<_ExploreView> {
itemCount: _activeRooms.length,
itemBuilder: (context, index) {
final room = _activeRooms[index];
return _buildRoomCard(room, settings);
return _buildRoomCard(room, settings, l10n);
},
),
),
);
}
Widget _buildRoomCard(dynamic room, SettingsProvider settings) {
Widget _buildRoomCard(dynamic room, SettingsProvider settings, AppLocalizations l10n) {
final roomId = room['room_id'].toString();
return Card(
elevation: 4,
@@ -294,12 +299,12 @@ class _ExploreViewState extends State<_ExploreView> {
top: 8,
left: 8,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Row(
child: const Row(
children: [
Icon(Icons.circle, size: 8, color: Colors.white),
SizedBox(width: 4),
@@ -331,7 +336,7 @@ class _ExploreViewState extends State<_ExploreView> {
radius: 16,
child: Text(room['user_id'].toString().substring(0, 1)),
),
SizedBox(width: 12),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -341,14 +346,14 @@ class _ExploreViewState extends State<_ExploreView> {
room['title'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
"Host ID: ${room['user_id']}",
style: TextStyle(fontSize: 12, color: Colors.grey),
"${l10n.hostId}: ${room['user_id']}",
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
@@ -29,10 +30,11 @@ class _LoginPageState extends State<LoginPage> {
}
void _handleLogin() async {
final l10n = AppLocalizations.of(context)!;
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Please fill in all fields")));
).showSnackBar(SnackBar(content: Text(l10n.fillAllFields)));
return;
}
@@ -53,7 +55,7 @@ class _LoginPageState extends State<LoginPage> {
if (!mounted) {
return;
}
final error = jsonDecode(response.body)['error'] ?? "Login Failed";
final error = jsonDecode(response.body)['error'] ?? l10n.loginFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
@@ -63,7 +65,7 @@ class _LoginPageState extends State<LoginPage> {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Network Error: Could not connect to server")),
SnackBar(content: Text(l10n.networkError)),
);
} finally {
if (mounted) setState(() => _isLoading = false);
@@ -72,11 +74,13 @@ class _LoginPageState extends State<LoginPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
icon: Icon(Icons.settings),
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
@@ -88,7 +92,7 @@ class _LoginPageState extends State<LoginPage> {
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -98,7 +102,7 @@ class _LoginPageState extends State<LoginPage> {
size: 80,
color: Theme.of(context).colorScheme.primary,
),
SizedBox(height: 16),
const SizedBox(height: 16),
Text(
"HIGHTUBE",
style: TextStyle(
@@ -108,11 +112,11 @@ class _LoginPageState extends State<LoginPage> {
color: Theme.of(context).colorScheme.primary,
),
),
Text(
const Text(
"Open Source Live Platform",
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 48),
const SizedBox(height: 48),
// Fields
TextField(
@@ -120,14 +124,14 @@ class _LoginPageState extends State<LoginPage> {
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
decoration: InputDecoration(
labelText: "Username",
prefixIcon: Icon(Icons.person),
labelText: l10n.username,
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
focusNode: _passwordFocusNode,
@@ -139,14 +143,14 @@ class _LoginPageState extends State<LoginPage> {
}
},
decoration: InputDecoration(
labelText: "Password",
prefixIcon: Icon(Icons.lock),
labelText: l10n.password,
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
SizedBox(height: 32),
const SizedBox(height: 32),
// Login Button
SizedBox(
@@ -160,14 +164,14 @@ class _LoginPageState extends State<LoginPage> {
),
),
child: _isLoading
? CircularProgressIndicator()
? const CircularProgressIndicator()
: Text(
"LOGIN",
style: TextStyle(fontWeight: FontWeight.bold),
l10n.login,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
// Register Link
TextButton(
@@ -175,7 +179,7 @@ class _LoginPageState extends State<LoginPage> {
context,
MaterialPageRoute(builder: (_) => RegisterPage()),
),
child: Text("Don't have an account? Create one"),
child: Text(l10n.dontHaveAccount),
),
],
),

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
@@ -42,9 +43,10 @@ class _MyStreamPageState extends State<MyStreamPage> {
if (!mounted) {
return;
}
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Failed to fetch room info")));
).showSnackBar(SnackBar(content: Text(l10n?.failedToFetchRoomInfo ?? "Failed to fetch room info")));
} finally {
if (mounted) {
setState(() => _isLoading = false);
@@ -55,46 +57,47 @@ class _MyStreamPageState extends State<MyStreamPage> {
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>();
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text("My Stream Console")),
appBar: AppBar(title: Text(l10n.myStreamConsole)),
body: _isLoading
? Center(child: CircularProgressIndicator())
? const Center(child: CircularProgressIndicator())
: _roomInfo == null
? Center(child: Text("No room info found."))
? Center(child: Text(l10n.noRoomInfo))
: SingleChildScrollView(
padding: EdgeInsets.all(20),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoCard(
title: "Room Title",
title: l10n.roomTitle,
value: _roomInfo!['title'],
icon: Icons.edit,
onTap: () {
// TODO: Implement title update API later
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Title editing coming soon!")),
const SnackBar(content: Text("Title editing coming soon!")),
);
},
),
SizedBox(height: 20),
const SizedBox(height: 20),
_buildInfoCard(
title: "RTMP Server URL",
title: l10n.rtmpServerUrl,
value: settings.rtmpUrl,
icon: Icons.copy,
onTap: () {
Clipboard.setData(ClipboardData(text: settings.rtmpUrl));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Server URL copied to clipboard"),
content: Text(l10n.copiedToClipboard),
),
);
},
),
SizedBox(height: 20),
const SizedBox(height: 20),
_buildInfoCard(
title: "Stream Key (Keep Secret!)",
title: l10n.streamKey,
value: _roomInfo!['stream_key'],
icon: Icons.copy,
isSecret: true,
@@ -104,18 +107,18 @@ class _MyStreamPageState extends State<MyStreamPage> {
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Stream Key copied to clipboard"),
content: Text(l10n.copiedToClipboard),
),
);
},
),
SizedBox(height: 24),
const SizedBox(height: 24),
AndroidQuickStreamPanel(
rtmpBaseUrl: settings.rtmpUrl,
streamKey: _roomInfo!['stream_key'],
),
SizedBox(height: 30),
Center(
const SizedBox(height: 30),
const Center(
child: Column(
children: [
Icon(Icons.info_outline, color: Colors.grey),
@@ -143,10 +146,10 @@ class _MyStreamPageState extends State<MyStreamPage> {
}) {
return Card(
child: ListTile(
title: Text(title, style: TextStyle(fontSize: 12, color: Colors.grey)),
title: Text(title, style: const TextStyle(fontSize: 12, color: Colors.grey)),
subtitle: Text(
isSecret ? "••••••••••••••••" : value,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
trailing: IconButton(icon: Icon(icon), onPressed: onTap),
),

View File

@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
@@ -271,6 +272,7 @@ class _PlayerPageState extends State<PlayerPage> {
Future<void> _openVolumeSheet() async {
_showControls();
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
builder: (context) {
@@ -284,7 +286,7 @@ class _PlayerPageState extends State<PlayerPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Volume',
l10n.volume,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
@@ -353,6 +355,7 @@ class _PlayerPageState extends State<PlayerPage> {
if (!mounted) {
return;
}
final l10n = AppLocalizations.of(context)!;
final nextResolution = await showModalBottomSheet<String>(
context: context,
@@ -364,11 +367,11 @@ class _PlayerPageState extends State<PlayerPage> {
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text('Playback Resolution'),
title: Text(l10n.playbackResolution),
subtitle: Text(
available.length > 1
? 'Select an available transcoded stream.'
: 'Only the source stream is available right now.',
? l10n.playbackOptionsDesc
: l10n.sourceOnlyDesc,
),
),
...options.map((option) {
@@ -382,8 +385,8 @@ class _PlayerPageState extends State<PlayerPage> {
),
title: Text(option),
subtitle: enabled
? const Text('Available now')
: const Text('Waiting for backend transcoding output'),
? Text(l10n.availableNow)
: Text(l10n.waitingForTranscoding),
onTap: enabled ? () => Navigator.pop(context, option) : null,
);
}),
@@ -590,6 +593,7 @@ class _PlayerPageState extends State<PlayerPage> {
}
Widget _buildPlaybackControls() {
final l10n = AppLocalizations.of(context)!;
return IgnorePointer(
ignoring: !_controlsVisible,
child: AnimatedOpacity(
@@ -620,7 +624,7 @@ class _PlayerPageState extends State<PlayerPage> {
children: [
_buildControlButton(
icon: Icons.refresh,
label: "Refresh",
label: l10n.refresh,
onPressed: _refreshPlayer,
),
_buildControlButton(
@@ -629,19 +633,19 @@ class _PlayerPageState extends State<PlayerPage> {
: _volume < 0.5
? Icons.volume_down
: Icons.volume_up,
label: "Volume",
label: l10n.volume,
onPressed: _openVolumeSheet,
),
_buildControlButton(
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
label: _showDanmaku ? "Danmaku On" : "Danmaku Off",
label: _showDanmaku ? l10n.danmakuOn : l10n.danmakuOff,
onPressed: _toggleDanmaku,
),
_buildControlButton(
icon: _isFullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen",
label: _isFullscreen ? l10n.exitFullscreen : l10n.fullscreen,
onPressed: _toggleFullscreen,
),
_buildControlButton(
@@ -679,23 +683,24 @@ class _PlayerPageState extends State<PlayerPage> {
// 抽离聊天区域组件
Widget _buildChatSection() {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row(
children: [
Icon(Icons.chat_bubble_outline, size: 16),
SizedBox(width: 8),
Text("Live Chat", style: TextStyle(fontWeight: FontWeight.bold)),
const Icon(Icons.chat_bubble_outline, size: 16),
const SizedBox(width: 8),
Text(l10n.liveChat, style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
),
Expanded(
child: ListView.builder(
reverse: true,
padding: EdgeInsets.all(8),
padding: const EdgeInsets.all(8),
itemCount: _messages.length,
itemBuilder: (context, index) {
final m = _messages[index];
@@ -703,7 +708,7 @@ class _PlayerPageState extends State<PlayerPage> {
},
),
),
Divider(height: 1),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
@@ -712,11 +717,11 @@ class _PlayerPageState extends State<PlayerPage> {
child: TextField(
controller: _msgController,
decoration: InputDecoration(
hintText: "Send a message...",
hintText: l10n.sendMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: EdgeInsets.symmetric(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
@@ -15,8 +16,9 @@ class _RegisterPageState extends State<RegisterPage> {
bool _isLoading = false;
void _handleRegister() async {
final l10n = AppLocalizations.of(context)!;
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in all fields")));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(l10n.fillAllFields)));
return;
}
@@ -27,14 +29,14 @@ class _RegisterPageState extends State<RegisterPage> {
try {
final response = await api.register(_usernameController.text, _passwordController.text);
if (response.statusCode == 201) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Account created! Please login.")));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(l10n.accountCreated)));
Navigator.pop(context);
} else {
final error = jsonDecode(response.body)['error'] ?? "Registration Failed";
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error")));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(l10n.networkError)));
} finally {
if (mounted) setState(() => _isLoading = false);
}
@@ -42,42 +44,43 @@ class _RegisterPageState extends State<RegisterPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text("Create Account")),
appBar: AppBar(title: Text(l10n.createAccount)),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_add_outlined, size: 64, color: Theme.of(context).colorScheme.primary),
SizedBox(height: 24),
const SizedBox(height: 24),
Text(
"Join Hightube",
l10n.joinHightube,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
),
SizedBox(height: 48),
const SizedBox(height: 48),
TextField(
controller: _usernameController,
decoration: InputDecoration(
labelText: "Desired Username",
prefixIcon: Icon(Icons.person),
labelText: l10n.desiredUsername,
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: "Password",
prefixIcon: Icon(Icons.lock),
labelText: l10n.password,
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
SizedBox(height: 32),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
@@ -86,13 +89,13 @@ class _RegisterPageState extends State<RegisterPage> {
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: _isLoading ? CircularProgressIndicator() : Text("REGISTER", style: TextStyle(fontWeight: FontWeight.bold)),
child: _isLoading ? const CircularProgressIndicator() : Text(l10n.register, style: const TextStyle(fontWeight: FontWeight.bold)),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text("Already have an account? Login here"),
child: Text(l10n.alreadyHaveAccount),
),
],
),

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
@@ -88,21 +89,21 @@ class _SettingsPageState extends State<SettingsPage> {
}
}
Future<void> _confirmLogout(AuthProvider auth) async {
Future<void> _confirmLogout(AuthProvider auth, AppLocalizations l10n) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text("Confirm Logout"),
content: const Text("Are you sure you want to log out now?"),
title: Text(l10n.confirmLogout),
content: Text(l10n.confirmLogoutDesc),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text("Logout"),
child: Text(l10n.logout),
),
],
);
@@ -119,10 +120,11 @@ class _SettingsPageState extends State<SettingsPage> {
final auth = context.watch<AuthProvider>();
final settings = context.watch<SettingsProvider>();
final isAuthenticated = auth.isAuthenticated;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold)),
title: Text(l10n.settings, style: TextStyle(fontWeight: FontWeight.bold)),
centerTitle: true,
),
body: SingleChildScrollView(
@@ -134,12 +136,58 @@ class _SettingsPageState extends State<SettingsPage> {
_buildProfileSection(auth),
const SizedBox(height: 32),
],
_buildSectionTitle("Network Configuration"),
_buildSectionTitle(l10n.language),
const SizedBox(height: 16),
DropdownButtonFormField<Locale?>(
initialValue: settings.locale == null
? null
: AppLocalizations.supportedLocales.cast<Locale?>().firstWhere(
(l) => l?.languageCode == settings.locale?.languageCode &&
l?.scriptCode == settings.locale?.scriptCode,
orElse: () => null,
),
decoration: InputDecoration(
labelText: l10n.selectLanguage,
prefixIcon: const Icon(Icons.language),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: [
DropdownMenuItem(
value: null,
child: Text(l10n.system),
),
DropdownMenuItem(
value: const Locale('en'),
child: Text(l10n.english),
),
DropdownMenuItem(
value: const Locale('zh'),
child: Text(l10n.simplifiedChinese),
),
DropdownMenuItem(
value: const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
child: Text(l10n.traditionalChinese),
),
DropdownMenuItem(
value: const Locale('ja'),
child: Text(l10n.japanese),
),
],
onChanged: (Locale? newLocale) {
settings.setLocale(newLocale);
},
),
const SizedBox(height: 32),
_buildSectionTitle(l10n.networkConfiguration),
const SizedBox(height: 16),
TextField(
controller: _urlController,
decoration: InputDecoration(
labelText: "Backend Server URL",
labelText: l10n.backendServerUrl,
hintText: "http://127.0.0.1:8080",
prefixIcon: Icon(Icons.lan),
border: OutlineInputBorder(
@@ -157,13 +205,13 @@ class _SettingsPageState extends State<SettingsPage> {
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Server URL Updated"),
content: Text(l10n.serverUrlUpdated),
behavior: SnackBarBehavior.floating,
),
);
},
icon: Icon(Icons.save),
label: Text("Save Network Settings"),
label: Text(l10n.saveNetworkSettings),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
@@ -179,28 +227,28 @@ class _SettingsPageState extends State<SettingsPage> {
),
const SizedBox(height: 32),
_buildSectionTitle("Theme Customization"),
_buildSectionTitle(l10n.themeCustomization),
const SizedBox(height: 16),
Text(
"Appearance Mode",
l10n.appearanceMode,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 12),
SegmentedButton<ThemeMode>(
segments: const [
segments: [
ButtonSegment<ThemeMode>(
value: ThemeMode.system,
label: Text("System"),
label: Text(l10n.system),
icon: Icon(Icons.brightness_auto),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.light,
label: Text("Light"),
label: Text(l10n.light),
icon: Icon(Icons.light_mode),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.dark,
label: Text("Dark"),
label: Text(l10n.dark),
icon: Icon(Icons.dark_mode),
),
],
@@ -210,7 +258,7 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
const SizedBox(height: 20),
Text("Accent Color", style: Theme.of(context).textTheme.labelLarge),
Text(l10n.accentColor, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 12),
Wrap(
spacing: 12,
@@ -248,26 +296,26 @@ class _SettingsPageState extends State<SettingsPage> {
}).toList(),
),
const SizedBox(height: 32),
_buildSectionTitle("Explore"),
_buildSectionTitle(l10n.explore),
const SizedBox(height: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text("Live Preview Thumbnails"),
subtitle: const Text(
"Show cached snapshot covers for live rooms when available.",
title: Text(l10n.livePreviewThumbnails),
subtitle: Text(
l10n.livePreviewThumbnailsDesc,
),
value: settings.livePreviewThumbnailsEnabled,
onChanged: settings.setLivePreviewThumbnailsEnabled,
),
if (isAuthenticated) ...[
const SizedBox(height: 32),
_buildSectionTitle("Security"),
_buildSectionTitle(l10n.security),
const SizedBox(height: 16),
TextField(
controller: _oldPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: "Old Password",
labelText: l10n.oldPassword,
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -279,7 +327,7 @@ class _SettingsPageState extends State<SettingsPage> {
controller: _newPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: "New Password",
labelText: l10n.newPassword,
prefixIcon: const Icon(Icons.lock_reset),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -292,7 +340,7 @@ class _SettingsPageState extends State<SettingsPage> {
child: OutlinedButton.icon(
onPressed: _handleChangePassword,
icon: const Icon(Icons.update),
label: const Text("Change Password"),
label: Text(l10n.changePassword),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -304,9 +352,9 @@ class _SettingsPageState extends State<SettingsPage> {
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: () => _confirmLogout(auth),
onPressed: () => _confirmLogout(auth, l10n),
icon: const Icon(Icons.logout),
label: const Text("Logout"),
label: Text(l10n.logout),
style: FilledButton.styleFrom(
foregroundColor: Colors.redAccent,
padding: const EdgeInsets.symmetric(vertical: 14),